1# -*- coding: utf-8 -*- 2# Copyright: Ankitects Pty Ltd and contributors 3# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 4 5import copy, operator 6import unicodedata 7import json 8 9from anki.utils import intTime, ids2str 10from anki.hooks import runHook 11from anki.consts import * 12from anki.lang import _ 13from anki.errors import DeckRenameError 14 15# fixmes: 16# - make sure users can't set grad interval < 1 17 18defaultDeck = { 19 'newToday': [0, 0], # currentDay, count 20 'revToday': [0, 0], 21 'lrnToday': [0, 0], 22 'timeToday': [0, 0], # time in ms 23 'conf': 1, 24 'usn': 0, 25 'desc': "", 26 'dyn': 0, # anki uses int/bool interchangably here 27 'collapsed': False, 28 # added in beta11 29 'extendNew': 10, 30 'extendRev': 50, 31} 32 33defaultDynamicDeck = { 34 'newToday': [0, 0], 35 'revToday': [0, 0], 36 'lrnToday': [0, 0], 37 'timeToday': [0, 0], 38 'collapsed': False, 39 'dyn': 1, 40 'desc': "", 41 'usn': 0, 42 'delays': None, 43 'separate': True, 44 # list of (search, limit, order); we only use first two elements for now 45 'terms': [["", 100, 0]], 46 'resched': True, 47 'return': True, # currently unused 48 49 # v2 scheduler 50 "previewDelay": 10, 51} 52 53defaultConf = { 54 'name': _("Default"), 55 'new': { 56 'delays': [1, 10], 57 'ints': [1, 4, 7], # 7 is not currently used 58 'initialFactor': STARTING_FACTOR, 59 'separate': True, 60 'order': NEW_CARDS_DUE, 61 'perDay': 20, 62 # may not be set on old decks 63 'bury': False, 64 }, 65 'lapse': { 66 'delays': [10], 67 'mult': 0, 68 'minInt': 1, 69 'leechFails': 8, 70 # type 0=suspend, 1=tagonly 71 'leechAction': 0, 72 }, 73 'rev': { 74 'perDay': 200, 75 'ease4': 1.3, 76 'fuzz': 0.05, 77 'minSpace': 1, # not currently used 78 'ivlFct': 1, 79 'maxIvl': 36500, 80 # may not be set on old decks 81 'bury': False, 82 'hardFactor': 1.2, 83 }, 84 'maxTaken': 60, 85 'timer': 0, 86 'autoplay': True, 87 'replayq': True, 88 'mod': 0, 89 'usn': 0, 90} 91 92class DeckManager: 93 94 # Registry save/load 95 ############################################################# 96 97 def __init__(self, col): 98 self.col = col 99 100 def load(self, decks, dconf): 101 self.decks = json.loads(decks) 102 self.dconf = json.loads(dconf) 103 # set limits to within bounds 104 found = False 105 for c in list(self.dconf.values()): 106 for t in ('rev', 'new'): 107 pd = 'perDay' 108 if c[t][pd] > 999999: 109 c[t][pd] = 999999 110 self.save(c) 111 found = True 112 if not found: 113 self.changed = False 114 115 def save(self, g=None): 116 "Can be called with either a deck or a deck configuration." 117 if g: 118 g['mod'] = intTime() 119 g['usn'] = self.col.usn() 120 self.changed = True 121 122 def flush(self): 123 if self.changed: 124 self.col.db.execute("update col set decks=?, dconf=?", 125 json.dumps(self.decks), 126 json.dumps(self.dconf)) 127 self.changed = False 128 129 # Deck save/load 130 ############################################################# 131 132 def id(self, name, create=True, type=None): 133 "Add a deck with NAME. Reuse deck if already exists. Return id as int." 134 if type is None: 135 type = defaultDeck 136 name = name.replace('"', '') 137 name = unicodedata.normalize("NFC", name) 138 deck = self.byName(name) 139 if deck: 140 return int(deck["id"]) 141 if not create: 142 return None 143 g = copy.deepcopy(type) 144 if "::" in name: 145 # not top level; ensure all parents exist 146 name = self._ensureParents(name) 147 g['name'] = name 148 while 1: 149 id = intTime(1000) 150 if str(id) not in self.decks: 151 break 152 g['id'] = id 153 self.decks[str(id)] = g 154 self.save(g) 155 self.maybeAddToActive() 156 runHook("newDeck") 157 return int(id) 158 159 def rem(self, did, cardsToo=False, childrenToo=True): 160 "Remove the deck. If cardsToo, delete any cards inside." 161 if str(did) == '1': 162 # we won't allow the default deck to be deleted, but if it's a 163 # child of an existing deck then it needs to be renamed 164 deck = self.get(did) 165 if '::' in deck['name']: 166 base = deck['name'].split("::")[-1] 167 suffix = "" 168 while True: 169 # find an unused name 170 name = base + suffix 171 if not self.byName(name): 172 deck['name'] = name 173 self.save(deck) 174 break 175 suffix += "1" 176 return 177 # log the removal regardless of whether we have the deck or not 178 self.col._logRem([did], REM_DECK) 179 # do nothing else if doesn't exist 180 if not str(did) in self.decks: 181 return 182 deck = self.get(did) 183 if deck['dyn']: 184 # deleting a cramming deck returns cards to their previous deck 185 # rather than deleting the cards 186 self.col.sched.emptyDyn(did) 187 if childrenToo: 188 for name, id in self.children(did): 189 self.rem(id, cardsToo) 190 else: 191 # delete children first 192 if childrenToo: 193 # we don't want to delete children when syncing 194 for name, id in self.children(did): 195 self.rem(id, cardsToo) 196 # delete cards too? 197 if cardsToo: 198 # don't use cids(), as we want cards in cram decks too 199 cids = self.col.db.list( 200 "select id from cards where did=? or odid=?", did, did) 201 self.col.remCards(cids) 202 # delete the deck and add a grave 203 del self.decks[str(did)] 204 # ensure we have an active deck 205 if did in self.active(): 206 self.select(int(list(self.decks.keys())[0])) 207 self.save() 208 209 def allNames(self, dyn=True, forceDefault=True): 210 "An unsorted list of all deck names." 211 if dyn: 212 return [x['name'] for x in self.all(forceDefault=forceDefault)] 213 else: 214 return [x['name'] for x in self.all(forceDefault=forceDefault) if not x['dyn']] 215 216 def all(self, forceDefault=True): 217 "A list of all decks." 218 decks = list(self.decks.values()) 219 if not forceDefault and not self.col.db.scalar("select 1 from cards where did = 1 limit 1") and len(decks)>1: 220 decks = [deck for deck in decks if deck['id'] != 1] 221 return decks 222 223 def allIds(self): 224 return list(self.decks.keys()) 225 226 def collapse(self, did): 227 deck = self.get(did) 228 deck['collapsed'] = not deck['collapsed'] 229 self.save(deck) 230 231 def collapseBrowser(self, did): 232 deck = self.get(did) 233 collapsed = deck.get('browserCollapsed', False) 234 deck['browserCollapsed'] = not collapsed 235 self.save(deck) 236 237 def count(self): 238 return len(self.decks) 239 240 def get(self, did, default=True): 241 id = str(did) 242 if id in self.decks: 243 return self.decks[id] 244 elif default: 245 return self.decks['1'] 246 247 def byName(self, name): 248 """Get deck with NAME, ignoring case.""" 249 for m in list(self.decks.values()): 250 if self.equalName(m['name'], name): 251 return m 252 253 def update(self, g): 254 "Add or update an existing deck. Used for syncing and merging." 255 self.decks[str(g['id'])] = g 256 self.maybeAddToActive() 257 # mark registry changed, but don't bump mod time 258 self.save() 259 260 def rename(self, g, newName): 261 "Rename deck prefix to NAME if not exists. Updates children." 262 # make sure target node doesn't already exist 263 if self.byName(newName): 264 raise DeckRenameError(_("That deck already exists.")) 265 # make sure we're not nesting under a filtered deck 266 for p in self.parentsByName(newName): 267 if p['dyn']: 268 raise DeckRenameError(_("A filtered deck cannot have subdecks.")) 269 # ensure we have parents 270 newName = self._ensureParents(newName) 271 # rename children 272 for grp in self.all(): 273 if grp['name'].startswith(g['name'] + "::"): 274 grp['name'] = grp['name'].replace(g['name']+ "::", 275 newName + "::", 1) 276 self.save(grp) 277 # adjust name 278 g['name'] = newName 279 # ensure we have parents again, as we may have renamed parent->child 280 newName = self._ensureParents(newName) 281 self.save(g) 282 # renaming may have altered active did order 283 self.maybeAddToActive() 284 285 def renameForDragAndDrop(self, draggedDeckDid, ontoDeckDid): 286 draggedDeck = self.get(draggedDeckDid) 287 draggedDeckName = draggedDeck['name'] 288 ontoDeckName = self.get(ontoDeckDid)['name'] 289 290 if ontoDeckDid is None or ontoDeckDid == '': 291 if len(self._path(draggedDeckName)) > 1: 292 self.rename(draggedDeck, self._basename(draggedDeckName)) 293 elif self._canDragAndDrop(draggedDeckName, ontoDeckName): 294 draggedDeck = self.get(draggedDeckDid) 295 draggedDeckName = draggedDeck['name'] 296 ontoDeckName = self.get(ontoDeckDid)['name'] 297 assert ontoDeckName.strip() 298 self.rename(draggedDeck, ontoDeckName + "::" + self._basename(draggedDeckName)) 299 300 def _canDragAndDrop(self, draggedDeckName, ontoDeckName): 301 if draggedDeckName == ontoDeckName \ 302 or self._isParent(ontoDeckName, draggedDeckName) \ 303 or self._isAncestor(draggedDeckName, ontoDeckName): 304 return False 305 else: 306 return True 307 308 def _isParent(self, parentDeckName, childDeckName): 309 return self._path(childDeckName) == self._path(parentDeckName) + [ self._basename(childDeckName) ] 310 311 def _isAncestor(self, ancestorDeckName, descendantDeckName): 312 ancestorPath = self._path(ancestorDeckName) 313 return ancestorPath == self._path(descendantDeckName)[0:len(ancestorPath)] 314 315 def _path(self, name): 316 return name.split("::") 317 def _basename(self, name): 318 return self._path(name)[-1] 319 320 def _ensureParents(self, name): 321 "Ensure parents exist, and return name with case matching parents." 322 s = "" 323 path = self._path(name) 324 if len(path) < 2: 325 return name 326 for p in path[:-1]: 327 if not s: 328 s += p 329 else: 330 s += "::" + p 331 # fetch or create 332 did = self.id(s) 333 # get original case 334 s = self.name(did) 335 name = s + "::" + path[-1] 336 return name 337 338 # Deck configurations 339 ############################################################# 340 341 def allConf(self): 342 "A list of all deck config." 343 return list(self.dconf.values()) 344 345 def confForDid(self, did): 346 deck = self.get(did, default=False) 347 assert deck 348 if 'conf' in deck: 349 conf = self.getConf(deck['conf']) 350 conf['dyn'] = False 351 return conf 352 # dynamic decks have embedded conf 353 return deck 354 355 def getConf(self, confId): 356 return self.dconf[str(confId)] 357 358 def updateConf(self, g): 359 self.dconf[str(g['id'])] = g 360 self.save() 361 362 def confId(self, name, cloneFrom=None): 363 "Create a new configuration and return id." 364 if cloneFrom is None: 365 cloneFrom = defaultConf 366 c = copy.deepcopy(cloneFrom) 367 while 1: 368 id = intTime(1000) 369 if str(id) not in self.dconf: 370 break 371 c['id'] = id 372 c['name'] = name 373 self.dconf[str(id)] = c 374 self.save(c) 375 return id 376 377 def remConf(self, id): 378 "Remove a configuration and update all decks using it." 379 assert int(id) != 1 380 self.col.modSchema(check=True) 381 del self.dconf[str(id)] 382 for g in self.all(): 383 # ignore cram decks 384 if 'conf' not in g: 385 continue 386 if str(g['conf']) == str(id): 387 g['conf'] = 1 388 self.save(g) 389 390 def setConf(self, grp, id): 391 grp['conf'] = id 392 self.save(grp) 393 394 def didsForConf(self, conf): 395 dids = [] 396 for deck in list(self.decks.values()): 397 if 'conf' in deck and deck['conf'] == conf['id']: 398 dids.append(deck['id']) 399 return dids 400 401 def restoreToDefault(self, conf): 402 oldOrder = conf['new']['order'] 403 new = copy.deepcopy(defaultConf) 404 new['id'] = conf['id'] 405 new['name'] = conf['name'] 406 self.dconf[str(conf['id'])] = new 407 self.save(new) 408 # if it was previously randomized, resort 409 if not oldOrder: 410 self.col.sched.resortConf(new) 411 412 # Deck utils 413 ############################################################# 414 415 def name(self, did, default=False): 416 deck = self.get(did, default=default) 417 if deck: 418 return deck['name'] 419 return _("[no deck]") 420 421 def nameOrNone(self, did): 422 deck = self.get(did, default=False) 423 if deck: 424 return deck['name'] 425 return None 426 427 def setDeck(self, cids, did): 428 self.col.db.execute( 429 "update cards set did=?,usn=?,mod=? where id in "+ 430 ids2str(cids), did, self.col.usn(), intTime()) 431 432 def maybeAddToActive(self): 433 # reselect current deck, or default if current has disappeared 434 c = self.current() 435 self.select(c['id']) 436 437 def cids(self, did, children=False): 438 if not children: 439 return self.col.db.list("select id from cards where did=?", did) 440 dids = [did] 441 for name, id in self.children(did): 442 dids.append(id) 443 return self.col.db.list("select id from cards where did in "+ 444 ids2str(dids)) 445 446 def _recoverOrphans(self): 447 dids = list(self.decks.keys()) 448 mod = self.col.db.mod 449 self.col.db.execute("update cards set did = 1 where did not in "+ 450 ids2str(dids)) 451 self.col.db.mod = mod 452 453 def _checkDeckTree(self): 454 decks = self.col.decks.all() 455 decks.sort(key=operator.itemgetter('name')) 456 names = set() 457 458 for deck in decks: 459 # two decks with the same name? 460 if self.normalizeName(deck['name']) in names: 461 self.col.log("fix duplicate deck name", deck['name']) 462 deck['name'] += "%d" % intTime(1000) 463 self.save(deck) 464 465 # ensure no sections are blank 466 if not all(deck['name'].split("::")): 467 self.col.log("fix deck with missing sections", deck['name']) 468 deck['name'] = "recovered%d" % intTime(1000) 469 self.save(deck) 470 471 # immediate parent must exist 472 if "::" in deck['name']: 473 immediateParent = "::".join(deck['name'].split("::")[:-1]) 474 if self.normalizeName(immediateParent) not in names: 475 self.col.log("fix deck with missing parent", deck['name']) 476 self._ensureParents(deck['name']) 477 names.add(self.normalizeName(immediateParent)) 478 479 names.add(self.normalizeName(deck['name'])) 480 481 def checkIntegrity(self): 482 self._recoverOrphans() 483 self._checkDeckTree() 484 485 # Deck selection 486 ############################################################# 487 488 def active(self): 489 "The currrently active dids. Make sure to copy before modifying." 490 return self.col.conf['activeDecks'] 491 492 def selected(self): 493 "The currently selected did." 494 return self.col.conf['curDeck'] 495 496 def current(self): 497 return self.get(self.selected()) 498 499 def select(self, did): 500 "Select a new branch." 501 # make sure arg is an int 502 did = int(did) 503 # current deck 504 self.col.conf['curDeck'] = did 505 # and active decks (current + all children) 506 actv = self.children(did) 507 actv.sort() 508 self.col.conf['activeDecks'] = [did] + [a[1] for a in actv] 509 self.changed = True 510 511 def children(self, did): 512 "All children of did, as (name, id)." 513 name = self.get(did)['name'] 514 actv = [] 515 for g in self.all(): 516 if g['name'].startswith(name + "::"): 517 actv.append((g['name'], g['id'])) 518 return actv 519 520 def childDids(self, did, childMap): 521 def gather(node, arr): 522 for did, child in node.items(): 523 arr.append(did) 524 gather(child, arr) 525 526 arr = [] 527 gather(childMap[did], arr) 528 return arr 529 530 def childMap(self): 531 nameMap = self.nameMap() 532 childMap = {} 533 534 # go through all decks, sorted by name 535 for deck in sorted(self.all(), key=operator.itemgetter("name")): 536 node = {} 537 childMap[deck['id']] = node 538 539 # add note to immediate parent 540 parts = deck['name'].split("::") 541 if len(parts) > 1: 542 immediateParent = "::".join(parts[:-1]) 543 pid = nameMap[immediateParent]['id'] 544 childMap[pid][deck['id']] = node 545 546 return childMap 547 548 def parents(self, did, nameMap=None): 549 "All parents of did." 550 # get parent and grandparent names 551 parents = [] 552 for part in self.get(did)['name'].split("::")[:-1]: 553 if not parents: 554 parents.append(part) 555 else: 556 parents.append(parents[-1] + "::" + part) 557 # convert to objects 558 for c, p in enumerate(parents): 559 if nameMap: 560 deck = nameMap[p] 561 else: 562 deck = self.get(self.id(p)) 563 parents[c] = deck 564 return parents 565 566 def parentsByName(self, name): 567 "All existing parents of name" 568 if "::" not in name: 569 return [] 570 names = name.split("::")[:-1] 571 head = [] 572 parents = [] 573 574 while names: 575 head.append(names.pop(0)) 576 deck = self.byName("::".join(head)) 577 if deck: 578 parents.append(deck) 579 580 return parents 581 582 def nameMap(self): 583 return dict((d['name'], d) for d in self.decks.values()) 584 585 # Sync handling 586 ########################################################################## 587 588 def beforeUpload(self): 589 for d in self.all(): 590 d['usn'] = 0 591 for c in self.allConf(): 592 c['usn'] = 0 593 self.save() 594 595 # Dynamic decks 596 ########################################################################## 597 598 def newDyn(self, name): 599 "Return a new dynamic deck and set it as the current deck." 600 did = self.id(name, type=defaultDynamicDeck) 601 self.select(did) 602 return did 603 604 def isDyn(self, did): 605 return self.get(did)['dyn'] 606 607 @staticmethod 608 def normalizeName(name): 609 return unicodedata.normalize("NFC", name.lower()) 610 611 @staticmethod 612 def equalName(name1, name2): 613 return DeckManager.normalizeName(name1) == DeckManager.normalizeName(name2) 614