rework tag handling and remove cardTags

The tags tables were initially added to speed up the loading of the browser by
speeding up two operations: gathering a list of all tags to show in the
dropdown box, and finding cards with a given tag. The former functionality is
provided by the tags table, and the latter functionality by the cardTags
table.

Selective study is handled by groups now, which perform better since they
don't require a join or subselect, and can be embedded in the index. So the
only remaining benefit of cardTags is for the browser.

Performance testing indicates that cardTags is not saving us a large amount.
It only takes us 30ms to search a 50k card table for matches with a hot cache.
On a cold cache it means the facts table has to be loaded into memory, which
roughly doubles the load time with the default settings (we need to load the
cards table too, as we're sorting the cards), but that startup time was
necessary with certain settings in the past too (sorting by fact created for
example). With groups implemented, the cost of maintaining a cache just for
initial browser load time is hard to justify.

Other changes:

- the tags table has any missing tags added to it when facts are added/edited.
  This means old tags will stick around even when no cards reference them, but
  is much cheaper than reference counting or a separate table, and simplifies
  updates and syncing.
- the tags table has a modified field now so we can can sync it instead of
  having to scan all facts coming across in a sync
- priority field removed
- we no longer put model names or card templates into the tags table. There
  were two reasons we did this in the past: so we could cram/selective study
  them, and for plugins. Selective study uses groups now, and plugins can
  check the model's name instead (and most already do). This also does away
  with the somewhat confusing behaviour of names also being tags.
- facts have their tags as _tags now. You can get a list with tags(), but
  editing operations should use add/deleteTags() instead of manually editing
  the string.
This commit is contained in:
Damien Elmes 2011-03-04 22:09:12 +09:00
parent 7c76058f4b
commit 1d6dbf9900
11 changed files with 127 additions and 317 deletions

View file

@ -138,7 +138,7 @@ class Card(object):
return self.htmlQuestion(type="answer", align=align) return self.htmlQuestion(type="answer", align=align)
def _splitTags(self): def _splitTags(self):
return (self.fact.tags, self.fact.model.name, self.cardModel.name) return (self.fact._tags, self.fact.model.name, self.cardModel.name)
# Non-ORM # Non-ORM
########################################################################## ##########################################################################
@ -192,8 +192,3 @@ mapper(Card, cardsTable, properties={
'fact': relation(Fact, backref="cards", primaryjoin= 'fact': relation(Fact, backref="cards", primaryjoin=
cardsTable.c.factId == factsTable.c.id), cardsTable.c.factId == factsTable.c.id),
}) })
mapper(Fact, factsTable, properties={
'model': relation(Model),
'fields': relation(Field, backref="fact", order_by=Field.ordinal),
})

View file

@ -10,11 +10,10 @@ from anki.lang import _, ngettext
from anki.errors import DeckAccessError from anki.errors import DeckAccessError
from anki.stdmodels import BasicModel from anki.stdmodels import BasicModel
from anki.utils import parseTags, tidyHTML, genID, ids2str, hexifyID, \ from anki.utils import parseTags, tidyHTML, genID, ids2str, hexifyID, \
canonifyTags, joinTags, addTags, checksum, fieldChecksum, intTime canonifyTags, joinTags, addTags, deleteTags, checksum, fieldChecksum, intTime
from anki.revlog import logReview from anki.revlog import logReview
from anki.models import Model, CardModel, formatQA from anki.models import Model, CardModel, formatQA
from anki.fonts import toPlatformFont from anki.fonts import toPlatformFont
from anki.tags import tagIds, tagId
from operator import itemgetter from operator import itemgetter
from itertools import groupby from itertools import groupby
from anki.hooks import runHook, hookEmpty from anki.hooks import runHook, hookEmpty
@ -444,7 +443,7 @@ due > :now and due < :now""", now=time.time())
cards.append(card) cards.append(card)
# update card q/a # update card q/a
fact.setModified(True, self) fact.setModified(True, self)
self.updateFactTags([fact.id]) self.registerTags(fact.tags())
self.flushMod() self.flushMod()
if reset: if reset:
self.reset() self.reset()
@ -476,7 +475,7 @@ due > :now and due < :now""", now=time.time())
empty["text:"+k] = u"" empty["text:"+k] = u""
local["text:"+k] = local[k] local["text:"+k] = local[k]
empty['tags'] = "" empty['tags'] = ""
local['tags'] = fact.tags local['tags'] = fact._tags
try: try:
if (render(format, local) == if (render(format, local) ==
render(format, empty)): render(format, empty)):
@ -503,7 +502,6 @@ where factId = :fid and cardModelId = :cmid""",
card = anki.cards.Card( card = anki.cards.Card(
fact, cardModel, fact, cardModel,
fact.created+0.0001*cardModel.ordinal) fact.created+0.0001*cardModel.ordinal)
self.updateCardTags([card.id])
raise Exception("incorrect; not checking selective study") raise Exception("incorrect; not checking selective study")
self.newAvail += 1 self.newAvail += 1
ids.append(card.id) ids.append(card.id)
@ -581,7 +579,7 @@ where facts.id not in (select distinct factId from cards)""")
fact = self.newFact(model) fact = self.newFact(model)
for field in fact.fields: for field in fact.fields:
fact[field.name] = oldFact[field.name] fact[field.name] = oldFact[field.name]
fact.tags = oldFact.tags fact._tags = oldFact._tags
return fact return fact
# Cards # Cards
@ -609,21 +607,6 @@ where facts.id not in (select distinct factId from cards)""")
self.db.statement("delete from cards where id in %s" % strids) self.db.statement("delete from cards where id in %s" % strids)
# note deleted # note deleted
anki.graves.registerMany(self.db, anki.graves.CARD, ids) anki.graves.registerMany(self.db, anki.graves.CARD, ids)
# gather affected tags
tags = self.db.column0(
"select tagId from cardTags where cardId in %s" %
strids)
# delete
self.db.statement("delete from cardTags where cardId in %s" % strids)
# find out if they're used by anything else
unused = []
for tag in tags:
if not self.db.scalar(
"select 1 from cardTags where tagId = :d limit 1", d=tag):
unused.append(tag)
# delete unused
self.db.statement("delete from tags where id in %s" %
ids2str(unused))
# remove any dangling facts # remove any dangling facts
self.deleteDanglingFacts() self.deleteDanglingFacts()
self.refreshSession() self.refreshSession()
@ -819,7 +802,6 @@ where id in %s""" % ids2str(ids), new=new.id, ord=new.ordinal)
cardIds = self.db.column0( cardIds = self.db.column0(
"select id from cards where factId in %s" % "select id from cards where factId in %s" %
ids2str(factIds)) ids2str(factIds))
self.updateCardTags(cardIds)
self.refreshSession() self.refreshSession()
self.finishProgress() self.finishProgress()
@ -1095,9 +1077,12 @@ modified = :now
where cardModelId in %s""" % strids, now=time.time()) where cardModelId in %s""" % strids, now=time.time())
self.flushMod() self.flushMod()
# Tags: querying # Tags
########################################################################## ##########################################################################
def tagList(self):
return self.db.column0("select name from tags order by name")
def splitTagsList(self, where=""): def splitTagsList(self, where=""):
return self.db.all(""" return self.db.all("""
select cards.id, facts.tags, models.name, cardModels.name select cards.id, facts.tags, models.name, cardModels.name
@ -1112,221 +1097,62 @@ select cards.id from cards, facts where
facts.tags = "" facts.tags = ""
and cards.factId = facts.id""") and cards.factId = facts.id""")
def cardsWithTags(self, tagStr, search="and"):
tagIds = []
# get ids
for tag in tagStr.split(" "):
tag = tag.replace("*", "%")
if "%" in tag:
ids = self.db.column0(
"select id from tags where name like :tag", tag=tag)
if search == "and" and not ids:
return []
tagIds.append(ids)
else:
id = self.db.scalar(
"select id from tags where name = :tag", tag=tag)
if search == "and" and not id:
return []
tagIds.append(id)
# search for any
if search == "or":
return self.db.column0(
"select cardId from cardTags where tagId in %s" %
ids2str(tagIds))
else:
# search for all
l = []
for ids in tagIds:
if isinstance(ids, types.ListType):
l.append("select cardId from cardTags where tagId in %s" %
ids2str(ids))
else:
l.append("select cardId from cardTags where tagId = %d" %
ids)
q = " intersect ".join(l)
return self.db.column0(q)
def allTags(self):
return self.db.column0("select name from tags order by name")
def allTags_(self, where=""):
t = self.db.column0("select tags from facts %s" % where)
t += self.db.column0("select name from models")
t += self.db.column0("select name from cardModels")
return sorted(list(set(parseTags(joinTags(t)))))
def allUserTags(self):
return sorted(list(set(parseTags(joinTags(self.db.column0(
"select tags from facts"))))))
def factTags(self, ids):
return self.db.all("""
select id, tags from facts
where id in %s""" % ids2str(ids))
def cardHasTag(self, card, tag): def cardHasTag(self, card, tag):
id = tagId(self.db, tag, create=False) tags = self.db.scalar("select tags from fact where id = :fid",
if id: fid=card.factId)
return self.db.scalar( return tag.lower() in parseTags(tags.lower())
"select 1 from cardTags where cardId = :c and tagId = :t",
c=card.id, t=id)
# Tags: caching def updateFactTags(self, factIds=None):
########################################################################## "Add any missing tags to the tags list."
if factIds:
def updateFactTags(self, factIds): lim = " where id in " + ids2str(factIds)
self.updateCardTags(self.db.column0(
"select id from cards where factId in %s" %
ids2str(factIds)))
def updateModelTags(self, modelId):
self.updateCardTags(self.db.column0("""
select cards.id from cards, facts where
cards.factId = facts.id and
facts.modelId = :id""", id=modelId))
def updateCardTags(self, cardIds=None):
self.db.flush()
if cardIds is None:
self.db.statement("delete from cardTags")
self.db.statement("delete from tags")
tids = tagIds(self.db, self.allTags_())
rows = self.splitTagsList()
else: else:
self.db.statement("delete from cardTags where cardId in %s" % lim = ""
ids2str(cardIds)) self.registerTags(set(parseTags(
fids = ids2str(self.db.column0( " ".join(self.db.column0("select distinct tags from facts"+lim)))))
"select factId from cards where id in %s" %
ids2str(cardIds)))
tids = tagIds(self.db, self.allTags_(
where="where id in %s" % fids))
rows = self.splitTagsList(
where="and facts.id in %s" % fids)
d = []
for (id, fact, model, templ) in rows:
for tag in parseTags(fact):
d.append({"cardId": id,
"tagId": tids[tag.lower()],
"src": 0})
for tag in parseTags(model):
d.append({"cardId": id,
"tagId": tids[tag.lower()],
"src": 1})
for tag in parseTags(templ):
d.append({"cardId": id,
"tagId": tids[tag.lower()],
"src": 2})
if d:
self.db.statements("""
insert into cardTags
(cardId, tagId, type) values
(:cardId, :tagId, :src)""", d)
self.deleteUnusedTags()
def updateTagsForModel(self, model): def registerTags(self, tags):
cards = self.db.all(""" r = []
select cards.id, cards.cardModelId from cards, facts where for t in tags:
facts.modelId = :m and cards.factId = facts.id""", m=model.id) r.append({'t': t})
cardIds = [x[0] for x in cards]
factIds = self.db.column0("""
select facts.id from facts where
facts.modelId = :m""", m=model.id)
cmtags = " ".join([cm.name for cm in model.cardModels])
tids = tagIds(self.db, parseTags(model.tags) +
parseTags(cmtags))
self.db.statement("""
delete from cardTags where cardId in %s
and src in (1, 2)""" % ids2str(cardIds))
d = []
for tag in parseTags(model.tags):
for id in cardIds:
d.append({"cardId": id,
"tagId": tids[tag.lower()],
"src": 1})
cmtags = {}
for cm in model.cardModels:
cmtags[cm.id] = parseTags(cm.name)
for c in cards:
for tag in cmtags[c[1]]:
d.append({"cardId": c[0],
"tagId": tids[tag.lower()],
"src": 2})
if d:
self.db.statements("""
insert into cardTags
(cardId, tagId, src) values
(:cardId, :tagId, :src)""", d)
self.deleteUnusedTags()
# Tags: adding/removing in bulk
##########################################################################
# these could be optimized to use the tag cache in the future
def deleteUnusedTags(self):
self.db.statement("""
delete from tags where id not in (select distinct tagId from cardTags)""")
def addTags(self, ids, tags):
"Add tags in bulk. Caller must .reset()"
self.startProgress()
tlist = self.factTags(ids)
newTags = parseTags(tags)
now = time.time()
pending = []
for (id, tags) in tlist:
oldTags = parseTags(tags)
tmpTags = list(set(oldTags + newTags))
if tmpTags != oldTags:
pending.append(
{'id': id, 'now': now, 'tags': " ".join(tmpTags)})
self.db.statements(""" self.db.statements("""
update facts set insert or ignore into tags (modified, name) values (%d, :t)""" % intTime(),
tags = :tags, r)
modified = :now
where id = :id""", pending) def addTags(self, ids, tags, add=True):
factIds = [c['id'] for c in pending] "Add tags in bulk. TAGS is space-separated."
cardIds = self.db.column0( self.startProgress()
"select id from cards where factId in %s" % newTags = parseTags(tags)
ids2str(factIds)) # cache tag names
self.updateCardQACacheFromIds(factIds, type="facts") self.registerTags(newTags)
self.updateCardTags(cardIds) # find facts missing the tags
if add:
l = "tags not "
fn = addTags
else:
l = "tags "
fn = deleteTags
lim = " or ".join(
[l+"like :_%d" % c for c, t in enumerate(newTags)])
res = self.db.all(
"select id, tags from facts where " + lim,
**dict([("_%d" % x, '%% %s %%' % y) for x, y in enumerate(newTags)]))
# update tags
fids = []
def fix(row):
fids.append(row[0])
return {'id': row[0], 't': fn(tags, row[1])}
self.db.statements("""
update facts set tags = :t, modified = %d
where id = :id""" % intTime(), [fix(row) for row in res])
# update q/a cache
self.updateCardQACacheFromIds(fids, type="facts")
self.flushMod() self.flushMod()
self.finishProgress() self.finishProgress()
self.refreshSession() self.refreshSession()
def deleteTags(self, ids, tags): def deleteTags(self, ids, tags):
"Delete tags in bulk. Caller must .reset()" self.addTags(ids, tags, False)
self.startProgress()
tlist = self.factTags(ids)
newTags = parseTags(tags)
now = time.time()
pending = []
for (id, tags) in tlist:
oldTags = parseTags(tags)
tmpTags = oldTags[:]
for tag in newTags:
try:
tmpTags.remove(tag)
except ValueError:
pass
if tmpTags != oldTags:
pending.append(
{'id': id, 'now': now, 'tags': " ".join(tmpTags)})
self.db.statements("""
update facts set
tags = :tags,
modified = :now
where id = :id""", pending)
factIds = [c['id'] for c in pending]
cardIds = self.db.column0(
"select id from cards where factId in %s" %
ids2str(factIds))
self.updateCardQACacheFromIds(factIds, type="facts")
self.updateCardTags(cardIds)
self.flushMod()
self.finishProgress()
self.refreshSession()
# Finding cards # Finding cards
########################################################################## ##########################################################################
@ -1742,6 +1568,8 @@ where id in %s""" % ids2str(ids)):
f.tags = self.db.scalar(""" f.tags = self.db.scalar("""
select group_concat(name, " ") from tags t, cardTags ct select group_concat(name, " ") from tags t, cardTags ct
where cardId = :cid and ct.tagId = t.id""", cid=id) or u"" where cardId = :cid and ct.tagId = t.id""", cid=id) or u""
if f.tags:
f.tags = " " + f.tags + " "
except: except:
raise Exception("Your sqlite is too old.") raise Exception("Your sqlite is too old.")
cards = self.addFact(f) cards = self.addFact(f)
@ -1860,7 +1688,9 @@ select id from fields where factId not in (select id from facts)""")
"where allowEmptyAnswer is null or typeAnswer is null") "where allowEmptyAnswer is null or typeAnswer is null")
# fix tags # fix tags
self.updateProgress() self.updateProgress()
self.updateCardTags() self.db.statement("delete from tags")
self.updateFactTags()
print "should ensure tags having leading/trailing space"
# make sure ordinals are correct # make sure ordinals are correct
self.updateProgress() self.updateProgress()
self.db.statement(""" self.db.statement("""
@ -2241,16 +2071,10 @@ insert into groups values (1, :t, "Default", 1)""",
""" """
create table tags ( create table tags (
id integer not null, id integer not null,
modified integer not null,
name text not null collate nocase unique, name text not null collate nocase unique,
priority integer not null default 0,
primary key(id))""", primary key(id))""",
""" """
create table cardTags (
cardId integer not null,
tagId integer not null,
type integer not null,
primary key(tagId, cardId))""",
"""
create table groups ( create table groups (
id integer primary key autoincrement, id integer primary key autoincrement,
modified integer not null, modified integer not null,

View file

@ -6,7 +6,8 @@ import time
from anki.db import * from anki.db import *
from anki.errors import * from anki.errors import *
from anki.models import Model, FieldModel, fieldModelsTable from anki.models import Model, FieldModel, fieldModelsTable
from anki.utils import genID, stripHTMLMedia, fieldChecksum, intTime from anki.utils import genID, stripHTMLMedia, fieldChecksum, intTime, \
addTags, deleteTags, parseTags
from anki.hooks import runHook from anki.hooks import runHook
# Fields in a fact # Fields in a fact
@ -63,7 +64,9 @@ class Fact(object):
def __init__(self, model=None, pos=None): def __init__(self, model=None, pos=None):
self.model = model self.model = model
self.id = genID() self.id = genID()
self._tags = u""
if model: if model:
# creating
for fm in model.fieldModels: for fm in model.fieldModels:
self.fields.append(Field(fm)) self.fields.append(Field(fm))
self.pos = pos self.pos = pos
@ -101,6 +104,15 @@ class Fact(object):
except (IndexError, KeyError): except (IndexError, KeyError):
return default return default
def addTags(self, tags):
self._tags = addTags(tags, self._tags)
def deleteTags(self, tags):
self._tags = deleteTags(tags, self._tags)
def tags(self):
return parseTags(self._tags)
def assertValid(self): def assertValid(self):
"Raise an error if required fields are empty." "Raise an error if required fields are empty."
for field in self.fields: for field in self.fields:
@ -148,3 +160,9 @@ class Fact(object):
self.values())) self.values()))
for card in self.cards: for card in self.cards:
card.rebuildQA(deck) card.rebuildQA(deck)
mapper(Fact, factsTable, properties={
'model': relation(Model),
'fields': relation(Field, backref="fact", order_by=Field.ordinal),
'_tags': factsTable.c.tags
})

View file

@ -65,7 +65,7 @@ def findCardsWhere(deck, query):
q = "" q = ""
x = [] x = []
if tquery: if tquery:
x.append(" id in (%s)" % tquery) x.append(" factId in (%s)" % tquery)
if fquery: if fquery:
x.append(" factId in (%s)" % fquery) x.append(" factId in (%s)" % fquery)
if qquery: if qquery:
@ -397,16 +397,19 @@ def _findCards(deck, query):
else: else:
tquery += " intersect " tquery += " intersect "
elif isNeg: elif isNeg:
tquery += "select id from cards except " tquery += "select id from facts except "
if token == "none": if token == "none":
tquery += """ tquery += """
select cards.id from cards, facts where facts.tags = '' and cards.factId = facts.id """ select cards.id from cards, facts where facts.tags = '' and cards.factId = facts.id """
else: else:
token = token.replace("*", "%") token = token.replace("*", "%")
ids = deck.db.column0(""" if not token.startswith("%"):
select id from tags where name like :tag escape '\\'""", tag=token) token = "% " + token
if not token.endswith("%"):
token += " %"
args["_tag_%d" % c] = token
tquery += """ tquery += """
select cardId from cardTags where cardTags.tagId in %s""" % ids2str(ids) select id from facts where tags like :_tag_%d""" % c
elif type == SEARCH_TYPE: elif type == SEARCH_TYPE:
if qquery: if qquery:
if isNeg: if isNeg:
@ -449,6 +452,7 @@ select cardId from cardTags where cardTags.tagId in %s""" % ids2str(ids)
fidquery += "select id from cards except " fidquery += "select id from cards except "
fidquery += "select id from cards where factId in (%s)" % token fidquery += "select id from cards where factId in (%s)" % token
elif type == SEARCH_CARD: elif type == SEARCH_CARD:
print "search_card broken"
token = token.replace("*", "%") token = token.replace("*", "%")
ids = deck.db.column0(""" ids = deck.db.column0("""
select id from tags where name like :tag escape '\\'""", tag=token) select id from tags where name like :tag escape '\\'""", tag=token)

View file

@ -8,7 +8,6 @@ from heapq import *
from anki.db import * from anki.db import *
from anki.cards import Card from anki.cards import Card
from anki.utils import parseTags, ids2str from anki.utils import parseTags, ids2str
from anki.tags import tagIds
from anki.lang import _ from anki.lang import _
from anki.consts import * from anki.consts import *

View file

@ -34,7 +34,6 @@ def BasicModel():
m.addCardModel(CardModel(u'Forward', u'%(Front)s', u'%(Back)s')) m.addCardModel(CardModel(u'Forward', u'%(Front)s', u'%(Back)s'))
m.addCardModel(CardModel(u'Reverse', u'%(Back)s', u'%(Front)s', m.addCardModel(CardModel(u'Reverse', u'%(Back)s', u'%(Front)s',
active=False)) active=False))
m.tags = u"Basic"
return m return m
models['Basic'] = BasicModel models['Basic'] = BasicModel
@ -47,5 +46,4 @@ def RecoveryModel():
m.addFieldModel(FieldModel(u'Question', False, False)) m.addFieldModel(FieldModel(u'Question', False, False))
m.addFieldModel(FieldModel(u'Answer', False, False)) m.addFieldModel(FieldModel(u'Answer', False, False))
m.addCardModel(CardModel(u'Single', u'{{{Question}}}', u'{{{Answer}}}')) m.addCardModel(CardModel(u'Single', u'{{{Question}}}', u'{{{Answer}}}'))
m.tags = u"Recovery"
return m return m

View file

@ -26,6 +26,8 @@ SYNC_PORT = int(os.environ.get("SYNC_PORT") or 80)
SYNC_URL = "http://%s:%d/sync/" % (SYNC_HOST, SYNC_PORT) SYNC_URL = "http://%s:%d/sync/" % (SYNC_HOST, SYNC_PORT)
KEYS = ("models", "facts", "cards", "media") KEYS = ("models", "facts", "cards", "media")
# - need to add tags table syncing
########################################################################## ##########################################################################
# Monkey-patch httplib to incrementally send instead of chewing up large # Monkey-patch httplib to incrementally send instead of chewing up large
# amounts of memory, and track progress. # amounts of memory, and track progress.

View file

@ -1,29 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright: Damien Elmes <anki@ichi2.net>
# License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html
from anki.db import *
# Type: 0=fact, 1=model, 2=template
def tagId(s, tag, create=True):
"Return ID for tag, creating if necessary."
id = s.scalar("select id from tags where name = :tag", tag=tag)
if id or not create:
return id
s.statement("""
insert or ignore into tags
(name) values (:tag)""", tag=tag)
return s.scalar("select id from tags where name = :tag", tag=tag)
def tagIds(s, tags, create=True):
"Return an ID for all tags, creating if necessary."
ids = {}
if create:
s.statements("insert or ignore into tags (name) values (:tag)",
[{'tag': t} for t in tags])
tagsD = dict([(x.lower(), y) for (x, y) in s.all("""
select name, id from tags
where name in (%s)""" % ",".join([
"'%s'" % t.replace("'", "''") for t in tags]))])
return tagsD

View file

@ -44,14 +44,20 @@ cast(factor*1000 as int), reps, successive, noCount, 0, 0 from cards2""")
# tags # tags
########### ###########
moveTable(s, "tags") moveTable(s, "tags")
moveTable(s, "cardTags")
import deck import deck
deck.DeckStorage._addTables(engine) deck.DeckStorage._addTables(engine)
s.execute("insert or ignore into tags select id, tag, 0 from tags2") s.execute("insert or ignore into tags select id, :t, tag from tags2",
{'t':intTime()})
# tags should have a leading and trailing space if not empty, and not
# use commas
s.execute(""" s.execute("""
insert or ignore into cardTags select cardId, tagId, src from cardTags2""") update facts set tags = (case
when trim(tags) == "" then ""
else " " || replace(replace(trim(tags), ",", " "), " ", " ") || " "
end)
""")
s.execute("drop table tags2") s.execute("drop table tags2")
s.execute("drop table cardTags2") s.execute("drop table cardTags")
# facts # facts
########### ###########
s.execute(""" s.execute("""
@ -158,9 +164,6 @@ create index if not exists ix_media_chksum on media (chksum)""")
# deletion tracking # deletion tracking
db.execute(""" db.execute("""
create index if not exists ix_gravestones_delTime on gravestones (delTime)""") create index if not exists ix_gravestones_delTime on gravestones (delTime)""")
# tags
db.execute("""
create index if not exists ix_cardTags_cardId on cardTags (cardId)""")
def upgradeDeck(deck): def upgradeDeck(deck):
"Upgrade deck to the latest version." "Upgrade deck to the latest version."

View file

@ -233,11 +233,13 @@ to be integers."""
def parseTags(tags): def parseTags(tags):
"Parse a string and return a list of tags." "Parse a string and return a list of tags."
tags = re.split(" |, ?", tags) return [t for t in tags.split(" ") if t]
return [t.strip() for t in tags if t.strip()]
def joinTags(tags): def joinTags(tags):
return u" ".join(tags) "Join tags into a single string, with leading and trailing spaces."
if not tags:
return u""
return u" %s " % u" ".join(tags)
def canonifyTags(tags): def canonifyTags(tags):
"Strip leading/trailing/superfluous commas and duplicates." "Strip leading/trailing/superfluous commas and duplicates."
@ -246,26 +248,28 @@ def canonifyTags(tags):
def findTag(tag, tags): def findTag(tag, tags):
"True if TAG is in TAGS. Ignore case." "True if TAG is in TAGS. Ignore case."
if not isinstance(tags, types.ListType):
tags = parseTags(tags)
return tag.lower() in [t.lower() for t in tags] return tag.lower() in [t.lower() for t in tags]
def addTags(tagstr, tags): def addTags(addtags, tags):
"Add tags if they don't exist." "Add tags if they don't exist."
currentTags = parseTags(tags) currentTags = parseTags(tags)
for tag in parseTags(tagstr): for tag in parseTags(addtags):
if not findTag(tag, currentTags): if not findTag(tag, currentTags):
currentTags.append(tag) currentTags.append(tag)
return joinTags(currentTags) return joinTags(currentTags)
def deleteTags(tagstr, tags): def deleteTags(deltags, tags):
"Delete tags if they don't exists." "Delete tags if they don't exists."
currentTags = parseTags(tags) currentTags = parseTags(tags)
for tag in parseTags(tagstr): for tag in parseTags(deltags):
try: # find tags, ignoring case
currentTags.remove(tag) remove = []
except ValueError: for tx in currentTags:
pass if tag.lower() == tx.lower():
remove.append(tx)
# remove them
for r in remove:
currentTags.remove(r)
return joinTags(currentTags) return joinTags(currentTags)
# Misc # Misc

View file

@ -268,12 +268,12 @@ def test_findCards():
f = deck.newFact() f = deck.newFact()
f['Front'] = u'dog' f['Front'] = u'dog'
f['Back'] = u'cat' f['Back'] = u'cat'
f.tags = u"monkey" f.addTags(u"monkey")
deck.addFact(f) deck.addFact(f)
f = deck.newFact() f = deck.newFact()
f['Front'] = u'goats are fun' f['Front'] = u'goats are fun'
f['Back'] = u'sheep' f['Back'] = u'sheep'
f.tags = u"sheep goat horse" f.addTags(u"sheep goat horse")
deck.addFact(f) deck.addFact(f)
f = deck.newFact() f = deck.newFact()
f['Front'] = u'cat' f['Front'] = u'cat'
@ -292,21 +292,13 @@ def test_findCards():
assert len(deck.findCards("are goats")) == 1 assert len(deck.findCards("are goats")) == 1
assert len(deck.findCards('"are goats"')) == 0 assert len(deck.findCards('"are goats"')) == 0
assert len(deck.findCards('"goats are"')) == 1 assert len(deck.findCards('"goats are"')) == 1
# make sure card templates and models match too deck.addTags(deck.db.column0("select id from cards"), "foo bar")
assert len(deck.findCards('tag:basic')) == 3 assert (len(deck.findCards("tag:foo")) ==
assert len(deck.findCards('tag:forward')) == 3 len(deck.findCards("tag:bar")) ==
deck.addModel(BasicModel()) 3)
f = deck.newFact() deck.deleteTags(deck.db.column0("select id from cards"), "foo")
f['Front'] = u'foo' assert len(deck.findCards("tag:foo")) == 0
f['Back'] = u'bar' assert len(deck.findCards("tag:bar")) == 3
deck.addFact(f)
deck.currentModel.cardModels[1].active = True
f = deck.newFact()
f['Front'] = u'baz'
f['Back'] = u'qux'
c = deck.addFact(f)
assert len(deck.findCards('tag:forward')) == 5
assert len(deck.findCards('tag:reverse')) == 1
def test_upgrade(): def test_upgrade():
src = os.path.expanduser("~/Scratch/upgrade.anki") src = os.path.expanduser("~/Scratch/upgrade.anki")