├── beetime ├── util.py ├── __init__.py ├── lookup.py ├── api.py ├── sync.py ├── settings.py └── settings_layout.ui ├── Beeminder_Sync.py ├── Makefile └── README.md /beetime/util.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | def getDayStamp(timestamp): 4 | """ Converts a Unix timestamp to a Ymd string.""" 5 | return datetime.date.fromtimestamp(timestamp).strftime('%Y%m%d') 6 | -------------------------------------------------------------------------------- /Beeminder_Sync.py: -------------------------------------------------------------------------------- 1 | # Description: Allows one to send review time, cards and/or cards/notes 2 | # added to Beeminder. 3 | # Copyright: Ian McB 4 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 5 | # Version: v1.6.2 6 | 7 | import beetime 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | base := Beeminder_Sync 2 | version := $(shell sed -n '/^\# Version: /p' <$(base).py | awk '{print $$3}') 3 | 4 | default: $(base) 5 | 6 | $(base): ui 7 | touch $@.zip 8 | rm $@.zip 9 | zip $@.zip $@.py beetime/*py 10 | mv $@.zip $@-$(version).zip 11 | 12 | 13 | ui: 14 | pyuic4 beetime/settings_layout.ui >beetime/settings_layout.py 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Description 2 | This add-on connects Anki to your Beeminder graphs. Currently it supports syncing your time spent reviewing, number of cards reviewed and/or number of cards/notes added, with more metrics planned. Discussion of features and bugs takes place over [on this thread on the Beeminder forum](http://forum.beeminder.com/t/announcing-beeminder-for-anki/2206). The published version of this add-on can be found over on [AnkiWeb](https://ankiweb.net/shared/info/1728790823) 3 | 4 | # Contributing 5 | Thanks! 6 | 7 | Just two suggestions regarding commits: use a verb in the active tense and add a context cue to the front of the commit message. 8 | 9 | # Notes 10 | Edit the .ui file in Qt Creator and build it using `pyuic4`. `make ui` should take care of that. 11 | -------------------------------------------------------------------------------- /beetime/__init__.py: -------------------------------------------------------------------------------- 1 | from settings import BeeminderSettings 2 | from sync import syncDispatch 3 | 4 | from anki.hooks import addHook 5 | 6 | from aqt import mw 7 | from aqt.qt import QAction, SIGNAL 8 | 9 | # settings menu item 10 | # ------------------ 11 | dialog = None 12 | def openBeeminderSettings(): 13 | global dialog 14 | if dialog is None: 15 | dialog = BeeminderSettings() 16 | dialog.display(mw) 17 | 18 | openSettings = QAction("Setup Beeminder sync...", mw) 19 | mw.connect(openSettings, SIGNAL("triggered()"), openBeeminderSettings) 20 | mw.form.menuTools.addAction(openSettings) 21 | 22 | # manual sync menu item 23 | # --------------------- 24 | manualSync = QAction("Sync with Beeminder", mw) 25 | mw.connect(manualSync, SIGNAL("triggered()"), lambda: syncDispatch(at='manual')) 26 | mw.form.menuTools.addAction(manualSync) 27 | 28 | # sync at shutdown hook 29 | # --------------------- 30 | addHook("unloadProfile", lambda: syncDispatch(at='shutdown')) 31 | -------------------------------------------------------------------------------- /beetime/lookup.py: -------------------------------------------------------------------------------- 1 | from util import getDayStamp 2 | 3 | def getDataPointId(col, goal_type, timestamp): 4 | """ Compare the cached dayStamp with the current one, return 5 | a tuple with as the first item the cached datapoint ID if 6 | the dayStamps match, otherwise None; the second item is 7 | a boolean indicating whether they match (and thus if we need 8 | to save the new ID and dayStamp. 9 | Disregard mention of the second item in the tuple. 10 | """ 11 | from sync import BEE 12 | if col.conf[BEE][goal_type]['overwrite'] and \ 13 | col.conf[BEE][goal_type]['lastupload'] == getDayStamp(timestamp): 14 | return col.conf[BEE][goal_type]['did'] 15 | else: 16 | return None 17 | 18 | def formatComment(numberOfCards, reviewTime): 19 | from anki.lang import _, ngettext 20 | from anki.utils import fmtTimeSpan 21 | 22 | # 2 lines ripped from the anki source 23 | msgp1 = ngettext("%d card", "%d cards", numberOfCards) % numberOfCards 24 | comment = _("studied %(a)s in %(b)s") % dict(a=msgp1, 25 | b=fmtTimeSpan(reviewTime, unit=1)) 26 | return comment 27 | 28 | def lookupReviewed(col): 29 | """Lookup the number of cards reviewed and the time spent reviewing them.""" 30 | cardsReviewed, reviewTime = col.db.first(""" 31 | select count(), sum(time)/1000 from revlog 32 | where id > ?""", (col.sched.dayCutoff - 86400) * 1000) 33 | 34 | cardsReviewed = cardsReviewed or 0 35 | reviewTime = reviewTime or 0 36 | 37 | return (cardsReviewed, reviewTime) 38 | 39 | def lookupAdded(col, added='cards'): 40 | cardsAdded = col.db.scalar("select count() from %s where id > %d" % (added, (col.sched.dayCutoff - 86400) * 1000)) 41 | return cardsAdded 42 | -------------------------------------------------------------------------------- /beetime/api.py: -------------------------------------------------------------------------------- 1 | import httplib, urllib 2 | import json 3 | 4 | def getApi(user, token, slug): 5 | """Get and return the datapoints for a given goal from Beeminder.""" 6 | return apiCall("GET", user, token, slug, None, None) 7 | 8 | def sendApi(user, token, slug, data, did=None): 9 | """Send or update a datapoint to a given Beeminder goal. If a 10 | datapoint ID (did) is given, the existing datapoint is updated. 11 | Otherwise a new datapoint is created. Returns the datapoint ID 12 | for use in caching. 13 | """ 14 | response = apiCall("POST", user, token, slug, data, did) 15 | return json.loads(response)['id'] 16 | 17 | def apiCall(requestType, user, token, slug, data, did): 18 | """Prepare an API request. 19 | 20 | Based on code by: muflax , 2012 21 | """ 22 | base = "www.beeminder.com" 23 | cmd = "datapoints" 24 | api = "/api/v1/users/%s/goals/%s/%s.json" % (user, slug, cmd) 25 | # if we have a datapoint ID, update an existing datapoint with PUT 26 | # otherwise POST a new one, with ID None 27 | if requestType == "POST" and did is not None: 28 | api = "/api/v1/users/%s/goals/%s/%s/%s.json" % (user, slug, cmd, did) 29 | requestType = "PUT" 30 | 31 | headers = {"Content-type": "application/x-www-form-urlencoded", 32 | "Accept": "text/plain"} 33 | 34 | if requestType == "GET": 35 | params = urllib.urlencode({"auth_token": token}) 36 | else: 37 | params = urllib.urlencode(data) 38 | 39 | conn = httplib.HTTPSConnection(base) 40 | conn.request(requestType, api, params, headers) 41 | response = conn.getresponse() 42 | if not response.status == 200: 43 | raise Exception("transmission failed:", response.status, response.reason, response.read()) 44 | responseBody = response.read() 45 | conn.close() 46 | return responseBody 47 | -------------------------------------------------------------------------------- /beetime/sync.py: -------------------------------------------------------------------------------- 1 | BEE = 'bee_conf' # name of key in anki configuration dict 2 | 3 | from aqt import mw, progress 4 | 5 | from util import getDayStamp 6 | from api import getApi, sendApi 7 | from lookup import * 8 | 9 | import datetime, time 10 | 11 | def syncDispatch(col=None, at=None): 12 | """Tally the time spent reviewing and send it to Beeminder. 13 | 14 | Based on code by: muflax , 2012 15 | """ 16 | col = col or mw.col 17 | if col is None: 18 | return 19 | 20 | if at == 'shutdown' and not col.conf[BEE]['shutdown'] or \ 21 | at == 'ankiweb' and not col.conf[BEE]['ankiweb'] or \ 22 | not col.conf[BEE]['enabled']: 23 | return 24 | 25 | mw.progress.start(immediate=True) 26 | mw.progress.update("Syncing with Beeminder...") 27 | 28 | # dayCutoff is the Unix timestamp of the user-set deadline 29 | # deadline is the hour after which we consider a new day to have started 30 | deadline = datetime.datetime.fromtimestamp(col.sched.dayCutoff).hour 31 | now = datetime.datetime.today() 32 | 33 | # upload all datapoints with an artificial time of 12 pm (noon) 34 | NOON = 12 35 | reportDatetime = datetime.datetime(now.year, now.month, now.day, NOON) 36 | if now.hour < deadline: 37 | reportDatetime -= datetime.timedelta(days=1) 38 | # convert the datetime object to a Unix timestamp 39 | reportTimestamp = time.mktime(reportDatetime.timetuple()) 40 | 41 | if isEnabled('time') or isEnabled('reviewed'): 42 | numberOfCards, reviewTime = lookupReviewed(col) 43 | comment = formatComment(numberOfCards, reviewTime) 44 | 45 | if isEnabled('time'): 46 | # convert seconds to hours (units is 0) or minutes (units is 1) 47 | # keep seconds if units is 2 48 | units = col.conf[BEE]['time']['units'] 49 | if units is 0: 50 | reviewTime /= 60.0 * 60.0 51 | elif units is 1: 52 | reviewTime /= 60.0 53 | # report time spent reviewing 54 | prepareApiCall(col, reportTimestamp, reviewTime, comment) 55 | 56 | if isEnabled('reviewed'): 57 | # report number of cards reviewed 58 | prepareApiCall(col, reportTimestamp, numberOfCards, comment, goal_type='reviewed') 59 | 60 | if isEnabled('added'): 61 | added = ["cards", "notes"][col.conf[BEE]['added']['type']] 62 | numberAdded = lookupAdded(col, added) 63 | # report number of cards or notes added 64 | prepareApiCall(col, reportTimestamp, numberAdded, 65 | "added %d %s" % (numberAdded, added), goal_type='added') 66 | 67 | mw.progress.finish() 68 | 69 | def prepareApiCall(col, timestamp, value, comment, goal_type='time'): 70 | """Prepare the API call to beeminder. 71 | 72 | Based on code by: muflax , 2012 73 | """ 74 | user = col.conf[BEE]['username'] 75 | token = col.conf[BEE]['token'] 76 | slug = col.conf[BEE][goal_type]['slug'] 77 | data = { 78 | "timestamp": timestamp, 79 | "value": value, 80 | "comment": comment, 81 | "auth_token": token} 82 | 83 | cachedDatapointId = getDataPointId(col, goal_type, timestamp) 84 | 85 | newDatapointId = sendApi(user, token, slug, data, cachedDatapointId) 86 | col.conf[BEE][goal_type]['lastupload'] = getDayStamp(timestamp) 87 | col.conf[BEE][goal_type]['did'] = newDatapointId 88 | col.setMod() 89 | 90 | def isEnabled(goal): 91 | return mw.col.conf[BEE][goal]['enabled'] 92 | -------------------------------------------------------------------------------- /beetime/settings.py: -------------------------------------------------------------------------------- 1 | from settings_layout import Ui_BeeminderSettings 2 | 3 | from aqt import mw 4 | from aqt.qt import * 5 | 6 | from sync import BEE 7 | 8 | class BeeminderSettings(QDialog): 9 | """Create a settings menu.""" 10 | def __init__(self): 11 | QDialog.__init__(self) 12 | 13 | self.mw = mw 14 | self.ui = Ui_BeeminderSettings() 15 | self.ui.setupUi(self) 16 | 17 | self.connect(self.ui.buttonBox, SIGNAL("rejected()"), self.onReject) 18 | self.connect(self.ui.buttonBox, SIGNAL("accepted()"), self.onAccept) 19 | 20 | defaultConfig = { 21 | "username": "", 22 | "token": "", 23 | "enabled": True, 24 | "shutdown": False, 25 | "ankiweb": False, 26 | "time": { 27 | "enabled": False, 28 | "slug": "", 29 | "did": None, 30 | "lastupload": None, 31 | "units": 0, 32 | "premium": False, 33 | "overwrite": True, 34 | "agg": 0}, 35 | "added": { 36 | "enabled": False, 37 | "slug": "", 38 | "did": None, 39 | "type": 0, 40 | "lastupload": None, 41 | "premium": False, 42 | "overwrite": True, 43 | "agg": 0}, 44 | "reviewed": { 45 | "enabled": False, 46 | "slug": "", 47 | "did": None, 48 | "lastupload": None, 49 | "premium": False, 50 | "overwrite": True, 51 | "agg": 0}} 52 | 53 | # for first-time users 54 | if not BEE in self.mw.col.conf: 55 | self.mw.col.conf[BEE] = defaultConfig 56 | 57 | # for users upgrading from v1.2 (to v1.4+) 58 | if not "time" in self.mw.col.conf[BEE]: 59 | # TODO: remove the duplication with defaultConfig (e.g. figure out 60 | # how to "add" dicts together) 61 | goalTypeConfig = { 62 | "enabled": False, 63 | "slug": "", 64 | "did": None, 65 | "lastupload": None, 66 | "premium": False, 67 | "overwrite": False, 68 | "agg": 0} 69 | self.mw.col.conf[BEE][u'time'] = goalTypeConfig 70 | self.mw.col.conf[BEE]['time']['units'] = 0 71 | self.mw.col.conf[BEE]['time']['did'] = self.mw.col.conf[BEE]['did'] 72 | self.mw.col.conf[BEE]['time']['lastupload'] = self.mw.col.conf[BEE]['lastupload'] 73 | self.mw.col.conf[BEE][u'added'] = goalTypeConfig 74 | self.mw.col.conf[BEE]['added']['type'] = 0 75 | self.mw.col.conf[BEE][u'reviewed'] = goalTypeConfig 76 | self.mw.col.setMod() 77 | print("Upgraded settings dict to enable caching & multiple goals") 78 | 79 | # for users upgrading from v1.6 80 | if self.mw.col.conf[BEE]['added']['type'] == "cards": 81 | self.mw.col.conf[BEE]['added']['type'] = 0 82 | print("Hotfix v1.6.1") 83 | 84 | def display(self, parent): 85 | self.ui.username.setText(self.mw.col.conf[BEE]['username']) 86 | self.ui.token.setText(self.mw.col.conf[BEE]['token']) 87 | self.ui.enabled.setChecked(self.mw.col.conf[BEE]['enabled']) 88 | self.ui.shutdown.setChecked(self.mw.col.conf[BEE]['shutdown']) 89 | self.ui.ankiweb.setChecked(self.mw.col.conf[BEE]['ankiweb']) 90 | 91 | self.ui.time_units.setCurrentIndex(self.mw.col.conf[BEE]['time']['units']) 92 | self.ui.added_type.setCurrentIndex(self.mw.col.conf[BEE]['added']['type']) 93 | 94 | self.ui.time_slug.setText(self.mw.col.conf[BEE]['time']['slug']) 95 | self.ui.time_enabled.setChecked(self.mw.col.conf[BEE]['time']['enabled']) 96 | self.ui.time_premium.setChecked(self.mw.col.conf[BEE]['time']['premium']) 97 | self.ui.time_agg.setCurrentIndex(self.mw.col.conf[BEE]['time']['agg']) 98 | 99 | self.ui.reviewed_slug.setText(self.mw.col.conf[BEE]['reviewed']['slug']) 100 | self.ui.reviewed_enabled.setChecked(self.mw.col.conf[BEE]['reviewed']['enabled']) 101 | self.ui.reviewed_premium.setChecked(self.mw.col.conf[BEE]['reviewed']['premium']) 102 | self.ui.reviewed_agg.setCurrentIndex(self.mw.col.conf[BEE]['reviewed']['agg']) 103 | 104 | self.ui.added_slug.setText(self.mw.col.conf[BEE]['added']['slug']) 105 | self.ui.added_enabled.setChecked(self.mw.col.conf[BEE]['added']['enabled']) 106 | self.ui.added_premium.setChecked(self.mw.col.conf[BEE]['added']['premium']) 107 | self.ui.added_agg.setCurrentIndex(self.mw.col.conf[BEE]['added']['agg']) 108 | 109 | self.parent = parent 110 | self.show() 111 | 112 | def onReject(self): 113 | self.close() 114 | 115 | def onAccept(self): 116 | self.onApply() 117 | self.close() 118 | 119 | def onApply(self): 120 | self.mw.col.conf[BEE]['username'] = self.ui.username.text() 121 | self.mw.col.conf[BEE]['token'] = self.ui.token.text() 122 | self.mw.col.conf[BEE]['enabled'] = self.ui.enabled.isChecked() 123 | self.mw.col.conf[BEE]['shutdown'] = self.ui.shutdown.isChecked() 124 | self.mw.col.conf[BEE]['ankiweb'] = self.ui.ankiweb.isChecked() 125 | 126 | self.mw.col.conf[BEE]['time']['units'] = self.ui.time_units.currentIndex() 127 | self.mw.col.conf[BEE]['added']['type'] = self.ui.added_type.currentIndex() 128 | 129 | self.mw.col.conf[BEE]['time']['slug'] = self.ui.time_slug.text() 130 | self.mw.col.conf[BEE]['time']['enabled'] = self.ui.time_enabled.isChecked() 131 | self.mw.col.conf[BEE]['time']['premium'] = self.ui.time_premium.isChecked() 132 | self.mw.col.conf[BEE]['time']['agg'] = self.ui.time_agg.currentIndex() 133 | 134 | self.mw.col.conf[BEE]['time']['overwrite'] = self.setOverwrite(self.mw.col.conf[BEE]['time']['premium'], 135 | self.mw.col.conf[BEE]['time']['agg']) 136 | 137 | self.mw.col.conf[BEE]['reviewed']['slug'] = self.ui.reviewed_slug.text() 138 | self.mw.col.conf[BEE]['reviewed']['enabled'] = self.ui.reviewed_enabled.isChecked() 139 | self.mw.col.conf[BEE]['reviewed']['premium'] = self.ui.reviewed_premium.isChecked() 140 | self.mw.col.conf[BEE]['reviewed']['agg'] = self.ui.reviewed_agg.currentIndex() 141 | self.mw.col.conf[BEE]['reviewed']['overwrite'] = self.setOverwrite(self.mw.col.conf[BEE]['reviewed']['premium'], 142 | self.mw.col.conf[BEE]['reviewed']['agg']) 143 | 144 | self.mw.col.conf[BEE]['added']['slug'] = self.ui.added_slug.text() 145 | self.mw.col.conf[BEE]['added']['enabled'] = self.ui.added_enabled.isChecked() 146 | self.mw.col.conf[BEE]['added']['premium'] = self.ui.added_premium.isChecked() 147 | self.mw.col.conf[BEE]['added']['agg'] = self.ui.added_agg.currentIndex() 148 | self.mw.col.conf[BEE]['added']['overwrite'] = self.setOverwrite(self.mw.col.conf[BEE]['added']['premium'], 149 | self.mw.col.conf[BEE]['added']['agg']) 150 | 151 | self.mw.col.setMod() 152 | 153 | def setOverwrite(self, premium, agg): 154 | return not premium or (premium and agg is 0) 155 | -------------------------------------------------------------------------------- /beetime/settings_layout.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | BeeminderSettings 4 | 5 | 6 | 7 | 0 8 | 0 9 | 539 10 | 501 11 | 12 | 13 | 14 | Beeminder Settings 15 | 16 | 17 | 18 | 19 | 10 20 | 10 21 | 521 22 | 481 23 | 24 | 25 | 26 | 27 | 28 | 29 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok 30 | 31 | 32 | 33 | 34 | 35 | 36 | true 37 | 38 | 39 | Enable Beeminder sync 40 | 41 | 42 | true 43 | 44 | 45 | 46 | 47 | 10 48 | 30 49 | 461 50 | 118 51 | 52 | 53 | 54 | 55 | 56 | 57 | true 58 | 59 | 60 | Sync at shutdown 61 | 62 | 63 | true 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | Username 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | API key 84 | 85 | 86 | 87 | 88 | 89 | 90 | Sync after synchronizing with AnkiWeb 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 10 100 | 160 101 | 501 102 | 281 103 | 104 | 105 | 106 | 2 107 | 108 | 109 | 110 | 111 | 0 112 | 0 113 | 501 114 | 188 115 | 116 | 117 | 118 | Sync review time 119 | 120 | 121 | 122 | 123 | 10 124 | 0 125 | 491 126 | 191 127 | 128 | 129 | 130 | Enable this goal 131 | 132 | 133 | true 134 | 135 | 136 | 137 | 138 | 20 139 | 20 140 | 431 141 | 161 142 | 143 | 144 | 145 | 146 | 147 | 148 | Goalname 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | hours 157 | 158 | 159 | 160 | 161 | minutes 162 | 163 | 164 | 165 | 166 | seconds 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | Goal units 175 | 176 | 177 | 178 | 179 | 180 | 181 | Use premium features 182 | 183 | 184 | true 185 | 186 | 187 | true 188 | 189 | 190 | 191 | 192 | 10 193 | 30 194 | 350 195 | 52 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | overwrite the datapoint (use with aggday "sum") 204 | 205 | 206 | 207 | 208 | keep multiple datapoints (use with "last", "max", ...) 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | Behaviour when syncing multiple times in one day 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 0 235 | 0 236 | 501 237 | 188 238 | 239 | 240 | 241 | Sync number of reviews 242 | 243 | 244 | 245 | 246 | 10 247 | 0 248 | 491 249 | 181 250 | 251 | 252 | 253 | Enable this goal 254 | 255 | 256 | true 257 | 258 | 259 | 260 | 261 | 20 262 | 20 263 | 431 264 | 131 265 | 266 | 267 | 268 | 269 | 270 | 271 | Goalname 272 | 273 | 274 | 275 | 276 | 277 | 278 | Use premium features 279 | 280 | 281 | true 282 | 283 | 284 | true 285 | 286 | 287 | 288 | 289 | 10 290 | 30 291 | 350 292 | 52 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | overwrite the datapoint (use with aggday "sum") 301 | 302 | 303 | 304 | 305 | keep multiple datapoints (use with "last", "max", ...) 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | Behaviour when syncing multiple times in one day 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 0 332 | 0 333 | 501 334 | 188 335 | 336 | 337 | 338 | Sync number of additions 339 | 340 | 341 | 342 | 343 | 10 344 | 0 345 | 491 346 | 191 347 | 348 | 349 | 350 | Enable this goal 351 | 352 | 353 | true 354 | 355 | 356 | 357 | 358 | 20 359 | 20 360 | 431 361 | 161 362 | 363 | 364 | 365 | 366 | 367 | 368 | Goalname 369 | 370 | 371 | 372 | 373 | 374 | 375 | Use premium features 376 | 377 | 378 | true 379 | 380 | 381 | true 382 | 383 | 384 | 385 | 386 | 10 387 | 30 388 | 350 389 | 52 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | overwrite the datapoint (use with aggday "sum") 398 | 399 | 400 | 401 | 402 | keep multiple datapoints (use with "last", "max", ...) 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | Behaviour when syncing multiple times in one day 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | cards 426 | 427 | 428 | 429 | 430 | notes 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | Type 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | enabled 455 | time_slug 456 | time_units 457 | time_premium 458 | time_agg 459 | 460 | 461 | 462 | 463 | --------------------------------------------------------------------------------