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 5""" 6Anki maintains a cache of used tags so it can quickly present a list of tags 7for autocomplete and in the browser. For efficiency, deletions are not 8tracked, so unused tags can only be removed from the list with a DB check. 9 10This module manages the tag cache and tags for notes. 11""" 12 13import json 14from anki.utils import intTime, ids2str 15from anki.hooks import runHook 16import re 17 18class TagManager: 19 20 # Registry save/load 21 ############################################################# 22 23 def __init__(self, col): 24 self.col = col 25 26 def load(self, json_): 27 self.tags = json.loads(json_) 28 self.changed = False 29 30 def flush(self): 31 if self.changed: 32 self.col.db.execute("update col set tags=?", 33 json.dumps(self.tags)) 34 self.changed = False 35 36 # Registering and fetching tags 37 ############################################################# 38 39 def register(self, tags, usn=None): 40 "Given a list of tags, add any missing ones to tag registry." 41 found = False 42 for t in tags: 43 if t not in self.tags: 44 found = True 45 self.tags[t] = self.col.usn() if usn is None else usn 46 self.changed = True 47 if found: 48 runHook("newTag") 49 50 def all(self): 51 return list(self.tags.keys()) 52 53 def registerNotes(self, nids=None): 54 "Add any missing tags from notes to the tags list." 55 # when called without an argument, the old list is cleared first. 56 if nids: 57 lim = " where id in " + ids2str(nids) 58 else: 59 lim = "" 60 self.tags = {} 61 self.changed = True 62 self.register(set(self.split( 63 " ".join(self.col.db.list("select distinct tags from notes"+lim))))) 64 65 def allItems(self): 66 return list(self.tags.items()) 67 68 def save(self): 69 self.changed = True 70 71 def byDeck(self, did, children=False): 72 basequery = "select n.tags from cards c, notes n WHERE c.nid = n.id" 73 if not children: 74 query = basequery + " AND c.did=?" 75 res = self.col.db.list(query, did) 76 return list(set(self.split(" ".join(res)))) 77 dids = [did] 78 for name, id in self.col.decks.children(did): 79 dids.append(id) 80 query = basequery + " AND c.did IN " + ids2str(dids) 81 res = self.col.db.list(query) 82 return list(set(self.split(" ".join(res)))) 83 84 # Bulk addition/removal from notes 85 ############################################################# 86 87 def bulkAdd(self, ids, tags, add=True): 88 "Add tags in bulk. TAGS is space-separated." 89 newTags = self.split(tags) 90 if not newTags: 91 return 92 # cache tag names 93 if add: 94 self.register(newTags) 95 # find notes missing the tags 96 if add: 97 l = "tags not " 98 fn = self.addToStr 99 else: 100 l = "tags " 101 fn = self.remFromStr 102 lim = " or ".join( 103 [l+"like :_%d" % c for c, t in enumerate(newTags)]) 104 res = self.col.db.all( 105 "select id, tags from notes where id in %s and (%s)" % ( 106 ids2str(ids), lim), 107 **dict([("_%d" % x, '%% %s %%' % y.replace('*', '%')) 108 for x, y in enumerate(newTags)])) 109 # update tags 110 nids = [] 111 def fix(row): 112 nids.append(row[0]) 113 return {'id': row[0], 't': fn(tags, row[1]), 'n':intTime(), 114 'u':self.col.usn()} 115 self.col.db.executemany( 116 "update notes set tags=:t,mod=:n,usn=:u where id = :id", 117 [fix(row) for row in res]) 118 119 def bulkRem(self, ids, tags): 120 self.bulkAdd(ids, tags, False) 121 122 # String-based utilities 123 ########################################################################## 124 125 def split(self, tags): 126 "Parse a string and return a list of tags." 127 return [t for t in tags.replace('\u3000', ' ').split(" ") if t] 128 129 def join(self, tags): 130 "Join tags into a single string, with leading and trailing spaces." 131 if not tags: 132 return "" 133 return " %s " % " ".join(tags) 134 135 def addToStr(self, addtags, tags): 136 "Add tags if they don't exist, and canonify." 137 currentTags = self.split(tags) 138 for tag in self.split(addtags): 139 if not self.inList(tag, currentTags): 140 currentTags.append(tag) 141 return self.join(self.canonify(currentTags)) 142 143 def remFromStr(self, deltags, tags): 144 "Delete tags if they exist." 145 def wildcard(pat, str): 146 pat = re.escape(pat).replace('\\*', '.*') 147 return re.match("^"+pat+"$", str, re.IGNORECASE) 148 currentTags = self.split(tags) 149 for tag in self.split(deltags): 150 # find tags, ignoring case 151 remove = [] 152 for tx in currentTags: 153 if (tag.lower() == tx.lower()) or wildcard(tag, tx): 154 remove.append(tx) 155 # remove them 156 for r in remove: 157 currentTags.remove(r) 158 return self.join(currentTags) 159 160 # List-based utilities 161 ########################################################################## 162 163 def canonify(self, tagList): 164 "Strip duplicates, adjust case to match existing tags, and sort." 165 strippedTags = [] 166 for t in tagList: 167 s = re.sub("[\"']", "", t) 168 for existingTag in self.tags: 169 if s.lower() == existingTag.lower(): 170 s = existingTag 171 strippedTags.append(s) 172 return sorted(set(strippedTags)) 173 174 def inList(self, tag, tags): 175 "True if TAG is in TAGS. Ignore case." 176 return tag.lower() in [t.lower() for t in tags] 177 178 # Sync handling 179 ########################################################################## 180 181 def beforeUpload(self): 182 for k in list(self.tags.keys()): 183 self.tags[k] = 0 184 self.save() 185