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