From 6d61af3386dffb1b94c8d14a45423a7471c65d71 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Sun, 6 May 2018 12:59:31 -0700 Subject: [PATCH 01/25] merge AnkiConnect and AnkiBridge classes --- AnkiConnect.py | 1523 +++++++++++++++++++++--------------------------- 1 file changed, 672 insertions(+), 851 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index 5699e22..7f0f780 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -27,22 +27,23 @@ import re import select import socket import sys + +from operator import itemgetter from time import time from unicodedata import normalize -from operator import itemgetter # # Constants # -API_VERSION = 5 +API_VERSION = 5 +NET_ADDRESS = os.getenv('ANKICONNECT_BIND_ADDRESS', '127.0.0.1') +NET_BACKLOG = 5 +NET_PORT = 8765 TICK_INTERVAL = 25 -URL_TIMEOUT = 10 -URL_UPGRADE = 'https://raw.githubusercontent.com/FooSoft/anki-connect/master/AnkiConnect.py' -NET_ADDRESS = os.getenv('ANKICONNECT_BIND_ADDRESS', '127.0.0.1') -NET_BACKLOG = 5 -NET_PORT = 8765 +URL_TIMEOUT = 10 +URL_UPGRADE = 'https://raw.githubusercontent.com/FooSoft/anki-connect/master/AnkiConnect.py' # @@ -118,20 +119,20 @@ def verifyStringList(strings): # -# AjaxRequest +# WebRequest # -class AjaxRequest: +class WebRequest: def __init__(self, headers, body): self.headers = headers self.body = body # -# AjaxClient +# WebClient # -class AjaxClient: +class WebClient: def __init__(self, sock, handler): self.sock = sock self.handler = handler @@ -195,14 +196,14 @@ class AjaxClient: return None, 0 body = data[headerLength : totalLength] - return AjaxRequest(headers, body), totalLength + return WebRequest(headers, body), totalLength # -# AjaxServer +# WebServer # -class AjaxServer: +class WebServer: def __init__(self, handler): self.handler = handler self.clients = [] @@ -244,7 +245,7 @@ class AjaxServer: clientSock = self.sock.accept()[0] if clientSock is not None: clientSock.setblocking(False) - self.clients.append(AjaxClient(clientSock, self.handlerWrapper)) + self.clients.append(WebClient(clientSock, self.handlerWrapper)) def advanceClients(self): @@ -345,651 +346,9 @@ class AnkiNoteParams: # AnkiBridge # -class AnkiBridge: - def storeMediaFile(self, filename, data): - self.deleteMediaFile(filename) - self.media().writeData(filename, base64.b64decode(data)) - - - def retrieveMediaFile(self, filename): - # based on writeData from anki/media.py - filename = os.path.basename(filename) - filename = normalize('NFC', filename) - filename = self.media().stripIllegal(filename) - - path = os.path.join(self.media().dir(), filename) - if os.path.exists(path): - with open(path, 'rb') as file: - return base64.b64encode(file.read()).decode('ascii') - - return False - - - def deleteMediaFile(self, filename): - self.media().syncDelete(filename) - - - def addNote(self, params): - collection = self.collection() - if collection is None: - raise Exception('Collection was not found.') - - note = self.createNote(params) - if note is None: - raise Exception('Failed to create note from params: ' + str(params)) - - if params.audio is not None and len(params.audio.fields) > 0: - data = download(params.audio.url) - if data is not None: - if params.audio.skipHash is None: - skip = False - else: - m = hashlib.md5() - m.update(data) - skip = params.audio.skipHash == m.hexdigest() - - if not skip: - audioInject(note, params.audio.fields, params.audio.filename) - self.media().writeData(params.audio.filename, data) - - self.startEditing() - collection.addNote(note) - collection.autosave() - self.stopEditing() - - return note.id - - - def canAddNote(self, note): - try: - return bool(self.createNote(note)) - except: - return False - - - def createNote(self, params): - collection = self.collection() - if collection is None: - raise Exception('Collection was not found.') - - model = collection.models.byName(params.modelName) - if model is None: - raise Exception('Model was not found for model: ' + params.modelName) - - deck = collection.decks.byName(params.deckName) - if deck is None: - raise Exception('Deck was not found for deck: ' + params.deckName) - - note = anki.notes.Note(collection, model) - note.model()['did'] = deck['id'] - note.tags = params.tags - - for name, value in params.fields.items(): - if name in note: - note[name] = value - - # Returns 1 if empty. 2 if duplicate. Otherwise returns False - duplicateOrEmpty = note.dupeOrEmpty() - if duplicateOrEmpty == 1: - raise Exception('Note was empty. Param were: ' + str(params)) - elif duplicateOrEmpty == 2: - raise Exception('Note is duplicate of existing note. Params were: ' + str(params)) - elif duplicateOrEmpty == False: - return note - - - def updateNoteFields(self, params): - collection = self.collection() - if collection is None: - raise Exception('Collection was not found.') - - note = collection.getNote(params['id']) - if note is None: - raise Exception('Failed to get note:{}'.format(params['id'])) - for name, value in params['fields'].items(): - if name in note: - note[name] = value - note.flush() - - - def addTags(self, notes, tags, add=True): - self.startEditing() - self.collection().tags.bulkAdd(notes, tags, add) - self.stopEditing() - - - def getTags(self): - return self.collection().tags.all() - - - def suspend(self, cards, suspend=True): - for card in cards: - isSuspended = self.isSuspended(card) - if suspend and isSuspended: - cards.remove(card) - elif not suspend and not isSuspended: - cards.remove(card) - - if cards: - self.startEditing() - if suspend: - self.collection().sched.suspendCards(cards) - else: - self.collection().sched.unsuspendCards(cards) - self.stopEditing() - return True - - return False - - - def isSuspended(self, card): - card = self.collection().getCard(card) - if card.queue == -1: - return True - else: - return False - - - def areSuspended(self, cards): - suspended = [] - for card in cards: - suspended.append(self.isSuspended(card)) - return suspended - - - def areDue(self, cards): - due = [] - for card in cards: - if self.findCards('cid:%s is:new' % card): - due.append(True) - continue - - date, ivl = self.collection().db.all('select id/1000.0, ivl from revlog where cid = ?', card)[-1] - if (ivl >= -1200): - if self.findCards('cid:%s is:due' % card): - due.append(True) - else: - due.append(False) - else: - if date - ivl <= time(): - due.append(True) - else: - due.append(False) - - return due - - - def getIntervals(self, cards, complete=False): - intervals = [] - for card in cards: - if self.findCards('cid:%s is:new' % card): - intervals.append(0) - continue - - interval = self.collection().db.list('select ivl from revlog where cid = ?', card) - if not complete: - interval = interval[-1] - intervals.append(interval) - return intervals - - - def startEditing(self): - self.window().requireReset() - - - def stopEditing(self): - if self.collection() is not None: - self.window().maybeReset() - - - def window(self): - return aqt.mw - - - def reviewer(self): - return self.window().reviewer - - - def collection(self): - return self.window().col - - - def scheduler(self): - return self.collection().sched - - - def multi(self, actions): - response = [] - for item in actions: - response.append(AnkiConnect.handler(ac, item)) - return response - - - def media(self): - collection = self.collection() - if collection is not None: - return collection.media - - - def modelNames(self): - collection = self.collection() - if collection is not None: - return collection.models.allNames() - - - def modelNamesAndIds(self): - models = {} - - modelNames = self.modelNames() - for model in modelNames: - mid = self.collection().models.byName(model)['id'] - mid = int(mid) # sometimes Anki stores the ID as a string - models[model] = mid - - return models - - - def modelNameFromId(self, modelId): - collection = self.collection() - if collection is not None: - model = collection.models.get(modelId) - if model is not None: - return model['name'] - - - def modelFieldNames(self, modelName): - collection = self.collection() - if collection is not None: - model = collection.models.byName(modelName) - if model is not None: - return [field['name'] for field in model['flds']] - - - def modelFieldsOnTemplates(self, modelName): - model = self.collection().models.byName(modelName) - - if model is not None: - templates = {} - for template in model['tmpls']: - fields = [] - - for side in ['qfmt', 'afmt']: - fieldsForSide = [] - - # based on _fieldsOnTemplate from aqt/clayout.py - matches = re.findall('{{[^#/}]+?}}', template[side]) - for match in matches: - # remove braces and modifiers - match = re.sub(r'[{}]', '', match) - match = match.split(':')[-1] - - # for the answer side, ignore fields present on the question side + the FrontSide field - if match == 'FrontSide' or side == 'afmt' and match in fields[0]: - continue - fieldsForSide.append(match) - - - fields.append(fieldsForSide) - - templates[template['name']] = fields - - return templates - - - def getDeckConfig(self, deck): - if not deck in self.deckNames(): - return False - - did = self.collection().decks.id(deck) - return self.collection().decks.confForDid(did) - - - def saveDeckConfig(self, config): - configId = str(config['id']) - if not configId in self.collection().decks.dconf: - return False - - mod = anki.utils.intTime() - usn = self.collection().usn() - - config['mod'] = mod - config['usn'] = usn - - self.collection().decks.dconf[configId] = config - self.collection().decks.changed = True - return True - - - def setDeckConfigId(self, decks, configId): - for deck in decks: - if not deck in self.deckNames(): - return False - - if not str(configId) in self.collection().decks.dconf: - return False - - for deck in decks: - did = str(self.collection().decks.id(deck)) - aqt.mw.col.decks.decks[did]['conf'] = configId - - return True - - - def cloneDeckConfigId(self, name, cloneFrom=1): - if not str(cloneFrom) in self.collection().decks.dconf: - return False - - cloneFrom = self.collection().decks.getConf(cloneFrom) - return self.collection().decks.confId(name, cloneFrom) - - - def removeDeckConfigId(self, configId): - if configId == 1 or not str(configId) in self.collection().decks.dconf: - return False - - self.collection().decks.remConf(configId) - return True - - - def deckNames(self): - collection = self.collection() - if collection is not None: - return collection.decks.allNames() - - - def deckNamesAndIds(self): - decks = {} - - deckNames = self.deckNames() - for deck in deckNames: - did = self.collection().decks.id(deck) - decks[deck] = did - - return decks - - - def deckNameFromId(self, deckId): - collection = self.collection() - if collection is not None: - deck = collection.decks.get(deckId) - if deck is not None: - return deck['name'] - - - def findNotes(self, query=None): - if query is not None: - return self.collection().findNotes(query) - else: - return [] - - - def findCards(self, query=None): - if query is not None: - return self.collection().findCards(query) - else: - return [] - - - def cardsInfo(self,cards): - result = [] - for cid in cards: - try: - card = self.collection().getCard(cid) - model = card.model() - note = card.note() - fields = {} - for info in model['flds']: - order = info['ord'] - name = info['name'] - fields[name] = {'value': note.fields[order], 'order': order} - - result.append({ - 'cardId': card.id, - 'fields': fields, - 'fieldOrder': card.ord, - 'question': card._getQA()['q'], - 'answer': card._getQA()['a'], - 'modelName': model['name'], - 'deckName': self.deckNameFromId(card.did), - 'css': model['css'], - 'factor': card.factor, - #This factor is 10 times the ease percentage, - # so an ease of 310% would be reported as 3100 - 'interval': card.ivl, - 'note': card.nid - }) - except TypeError as e: - # Anki will give a TypeError if the card ID does not exist. - # Best behavior is probably to add an 'empty card' to the - # returned result, so that the items of the input and return - # lists correspond. - result.append({}) - - return result - - - def notesInfo(self,notes): - result = [] - for nid in notes: - try: - note = self.collection().getNote(nid) - model = note.model() - - fields = {} - for info in model['flds']: - order = info['ord'] - name = info['name'] - fields[name] = {'value': note.fields[order], 'order': order} - - result.append({ - 'noteId': note.id, - 'tags' : note.tags, - 'fields': fields, - 'modelName': model['name'], - 'cards': self.collection().db.list( - 'select id from cards where nid = ? order by ord', note.id) - }) - except TypeError as e: - # Anki will give a TypeError if the note ID does not exist. - # Best behavior is probably to add an 'empty card' to the - # returned result, so that the items of the input and return - # lists correspond. - result.append({}) - return result - - - def getDecks(self, cards): - decks = {} - for card in cards: - did = self.collection().db.scalar('select did from cards where id = ?', card) - deck = self.collection().decks.get(did)['name'] - - if deck in decks: - decks[deck].append(card) - else: - decks[deck] = [card] - - return decks - - - def createDeck(self, deck): - self.startEditing() - deckId = self.collection().decks.id(deck) - self.stopEditing() - - return deckId - - - def changeDeck(self, cards, deck): - self.startEditing() - - did = self.collection().decks.id(deck) - mod = anki.utils.intTime() - usn = self.collection().usn() - - # normal cards - scids = anki.utils.ids2str(cards) - # remove any cards from filtered deck first - self.collection().sched.remFromDyn(cards) - - # then move into new deck - self.collection().db.execute('update cards set usn=?, mod=?, did=? where id in ' + scids, usn, mod, did) - self.stopEditing() - - - def deleteDecks(self, decks, cardsToo=False): - self.startEditing() - for deck in decks: - did = self.collection().decks.id(deck) - self.collection().decks.rem(did, cardsToo) - self.stopEditing() - - - def cardsToNotes(self, cards): - return self.collection().db.list('select distinct nid from cards where id in ' + anki.utils.ids2str(cards)) - - - def guiBrowse(self, query=None): - browser = aqt.dialogs.open('Browser', self.window()) - browser.activateWindow() - - if query is not None: - browser.form.searchEdit.lineEdit().setText(query) - if hasattr(browser, 'onSearch'): - browser.onSearch() - else: - browser.onSearchActivated() - - return browser.model.cards - - - def guiAddCards(self): - addCards = aqt.dialogs.open('AddCards', self.window()) - addCards.activateWindow() - - - def guiReviewActive(self): - return self.reviewer().card is not None and self.window().state == 'review' - - - def guiCurrentCard(self): - if not self.guiReviewActive(): - raise Exception('Gui review is not currently active.') - - reviewer = self.reviewer() - card = reviewer.card - model = card.model() - note = card.note() - - fields = {} - for info in model['flds']: - order = info['ord'] - name = info['name'] - fields[name] = {'value': note.fields[order], 'order': order} - - if card is not None: - return { - 'cardId': card.id, - 'fields': fields, - 'fieldOrder': card.ord, - 'question': card._getQA()['q'], - 'answer': card._getQA()['a'], - 'buttons': [b[0] for b in reviewer._answerButtonList()], - 'modelName': model['name'], - 'deckName': self.deckNameFromId(card.did), - 'css': model['css'] - } - - - def guiStartCardTimer(self): - if not self.guiReviewActive(): - return False - - card = self.reviewer().card - - if card is not None: - card.startTimer() - return True - else: - return False - - - def guiShowQuestion(self): - if self.guiReviewActive(): - self.reviewer()._showQuestion() - return True - else: - return False - - - def guiShowAnswer(self): - if self.guiReviewActive(): - self.window().reviewer._showAnswer() - return True - else: - return False - - - def guiAnswerCard(self, ease): - if not self.guiReviewActive(): - return False - - reviewer = self.reviewer() - if reviewer.state != 'answer': - return False - if ease <= 0 or ease > self.scheduler().answerButtons(reviewer.card): - return False - - reviewer._answerCard(ease) - return True - - - def guiDeckOverview(self, name): - collection = self.collection() - if collection is not None: - deck = collection.decks.byName(name) - if deck is not None: - collection.decks.select(deck['id']) - self.window().onOverview() - return True - - return False - - - def guiDeckBrowser(self): - self.window().moveToState('deckBrowser') - - - def guiDeckReview(self, name): - if self.guiDeckOverview(name): - self.window().moveToState('review') - return True - else: - return False - - - def guiExitAnki(self): - timer = QTimer() - def exitAnki(): - timer.stop() - self.window().close() - timer.timeout.connect(exitAnki) - timer.start(1000) # 1s should be enough to allow the response to be sent. - - - def sync(self): - self.window().onSync() - - -# -# AnkiConnect -# - - class AnkiConnect: def __init__(self): - self.anki = AnkiBridge() - self.server = AjaxServer(self.handler) + self.server = WebServer(self.handler) try: self.server.listen() @@ -999,7 +358,7 @@ class AnkiConnect: self.timer.start(TICK_INTERVAL) except: QMessageBox.critical( - self.anki.window(), + self.window(), 'AnkiConnect', 'Failed to listen on port {}.\nMake sure it is available and is not in use.'.format(NET_PORT) ) @@ -1048,163 +407,709 @@ class AnkiConnect: return reply['result'] - @webApi() - def multi(self, actions): - return self.anki.multi(actions) + def startEditing(self): + self.window().requireReset() + + + def stopEditing(self): + if self.collection() is not None: + self.window().maybeReset() + + + def window(self): + return aqt.mw + + + def reviewer(self): + return self.window().reviewer + + + def collection(self): + return self.window().col + + + def scheduler(self): + return self.collection().sched + + + def media(self): + collection = self.collection() + if collection is not None: + return collection.media + + + def createNote(self, params): + collection = self.collection() + if collection is None: + raise Exception('Collection was not found.') + + model = collection.models.byName(params.modelName) + if model is None: + raise Exception('Model was not found for model: ' + params.modelName) + + deck = collection.decks.byName(params.deckName) + if deck is None: + raise Exception('Deck was not found for deck: ' + params.deckName) + + note = anki.notes.Note(collection, model) + note.model()['did'] = deck['id'] + note.tags = params.tags + + for name, value in params.fields.items(): + if name in note: + note[name] = value + + # Returns 1 if empty. 2 if duplicate. Otherwise returns False + duplicateOrEmpty = note.dupeOrEmpty() + if duplicateOrEmpty == 1: + raise Exception('Note was empty. Param were: ' + str(params)) + elif duplicateOrEmpty == 2: + raise Exception('Note is duplicate of existing note. Params were: ' + str(params)) + elif duplicateOrEmpty == False: + return note @webApi() def storeMediaFile(self, filename, data): - return self.anki.storeMediaFile(filename, data) + self.deleteMediaFile(filename) + self.media().writeData(filename, base64.b64decode(data)) @webApi() def retrieveMediaFile(self, filename): - return self.anki.retrieveMediaFile(filename) + # based on writeData from anki/media.py + filename = os.path.basename(filename) + filename = normalize('NFC', filename) + filename = self.media().stripIllegal(filename) + + path = os.path.join(self.media().dir(), filename) + if os.path.exists(path): + with open(path, 'rb') as file: + return base64.b64encode(file.read()).decode('ascii') + + return False @webApi() def deleteMediaFile(self, filename): - return self.anki.deleteMediaFile(filename) - - - @webApi() - def deckNames(self): - return self.anki.deckNames() - - - @webApi() - def deckNamesAndIds(self): - return self.anki.deckNamesAndIds() - - - @webApi() - def modelNames(self): - return self.anki.modelNames() - - - @webApi() - def modelNamesAndIds(self): - return self.anki.modelNamesAndIds() - - - @webApi() - def modelFieldNames(self, modelName): - return self.anki.modelFieldNames(modelName) - - - @webApi() - def modelFieldsOnTemplates(self, modelName): - return self.anki.modelFieldsOnTemplates(modelName) - - - @webApi() - def getDeckConfig(self, deck): - return self.anki.getDeckConfig(deck) - - - @webApi() - def saveDeckConfig(self, config): - return self.anki.saveDeckConfig(config) - - - @webApi() - def setDeckConfigId(self, decks, configId): - return self.anki.setDeckConfigId(decks, configId) - - - @webApi() - def cloneDeckConfigId(self, name, cloneFrom=1): - return self.anki.cloneDeckConfigId(name, cloneFrom) - - - @webApi() - def removeDeckConfigId(self, configId): - return self.anki.removeDeckConfigId(configId) + self.media().syncDelete(filename) @webApi() def addNote(self, note): params = AnkiNoteParams(note) - if params.validate(): - return self.anki.addNote(params) + if not params.validate(): + raise Exception('Invalid note parameters') + collection = self.collection() + if collection is None: + raise Exception('Collection was not found.') - @webApi() - def addNotes(self, notes): - results = [] - for note in notes: - try: - params = AnkiNoteParams(note) - if params.validate(): - results.append(self.anki.addNote(params)) + note = self.createNote(params) + if note is None: + raise Exception('Failed to create note from params: ' + str(params)) + + if params.audio is not None and len(params.audio.fields) > 0: + data = download(params.audio.url) + if data is not None: + if params.audio.skipHash is None: + skip = False else: - results.append(None) - except Exception: - results.append(None) + m = hashlib.md5() + m.update(data) + skip = params.audio.skipHash == m.hexdigest() - return results + if not skip: + audioInject(note, params.audio.fields, params.audio.filename) + self.media().writeData(params.audio.filename, data) + + self.startEditing() + collection.addNote(note) + collection.autosave() + self.stopEditing() + + return note.id @webApi() - def updateNoteFields(self, note): - return self.anki.updateNoteFields(note) + def canAddNote(self, note): + params = AnkiNoteParams(note) + if not params.validate(): + return False + + try: + return bool(self.createNote(note)) + except: + return False @webApi() - def canAddNotes(self, notes): - results = [] - for note in notes: - params = AnkiNoteParams(note) - results.append(params.validate() and self.anki.canAddNote(params)) + def updateNoteFields(self, params): + collection = self.collection() + if collection is None: + raise Exception('Collection was not found.') - return results + note = collection.getNote(params['id']) + if note is None: + raise Exception('Failed to get note:{}'.format(params['id'])) + for name, value in params['fields'].items(): + if name in note: + note[name] = value + note.flush() @webApi() def addTags(self, notes, tags, add=True): - return self.anki.addTags(notes, tags, add) + self.startEditing() + self.collection().tags.bulkAdd(notes, tags, add) + self.stopEditing() @webApi() def removeTags(self, notes, tags): - return self.anki.addTags(notes, tags, False) + return self.addTags(notes, tags, False) @webApi() def getTags(self): - return self.anki.getTags() + return self.collection().tags.all() @webApi() def suspend(self, cards, suspend=True): - return self.anki.suspend(cards, suspend) + for card in cards: + isSuspended = self.isSuspended(card) + if suspend and isSuspended: + cards.remove(card) + elif not suspend and not isSuspended: + cards.remove(card) + + if cards: + self.startEditing() + if suspend: + self.collection().sched.suspendCards(cards) + else: + self.collection().sched.unsuspendCards(cards) + self.stopEditing() + return True + + return False @webApi() def unsuspend(self, cards): - return self.anki.suspend(cards, False) + self.suspend(cards, False) + + + @webApi() + def isSuspended(self, card): + card = self.collection().getCard(card) + return card.queue == -1 @webApi() def areSuspended(self, cards): - return self.anki.areSuspended(cards) + suspended = [] + for card in cards: + suspended.append(self.isSuspended(card)) + return suspended @webApi() def areDue(self, cards): - return self.anki.areDue(cards) + due = [] + for card in cards: + if self.findCards('cid:%s is:new' % card): + due.append(True) + continue + + date, ivl = self.collection().db.all('select id/1000.0, ivl from revlog where cid = ?', card)[-1] + if (ivl >= -1200): + if self.findCards('cid:%s is:due' % card): + due.append(True) + else: + due.append(False) + else: + if date - ivl <= time(): + due.append(True) + else: + due.append(False) + + return due @webApi() def getIntervals(self, cards, complete=False): - return self.anki.getIntervals(cards, complete) + intervals = [] + for card in cards: + if self.findCards('cid:%s is:new' % card): + intervals.append(0) + continue + + interval = self.collection().db.list('select ivl from revlog where cid = ?', card) + if not complete: + interval = interval[-1] + intervals.append(interval) + return intervals + + + @webApi() + def multi(self, actions): + response = [] + for item in actions: + response.append(self.handler(item)) + return response + + + @webApi() + def modelNames(self): + collection = self.collection() + if collection is not None: + return collection.models.allNames() + + + @webApi() + def modelNamesAndIds(self): + models = {} + + modelNames = self.modelNames() + for model in modelNames: + mid = self.collection().models.byName(model)['id'] + mid = int(mid) # sometimes Anki stores the ID as a string + models[model] = mid + + return models + + + @webApi() + def modelNameFromId(self, modelId): + collection = self.collection() + if collection is not None: + model = collection.models.get(modelId) + if model is not None: + return model['name'] + + + @webApi() + def modelFieldNames(self, modelName): + collection = self.collection() + if collection is not None: + model = collection.models.byName(modelName) + if model is not None: + return [field['name'] for field in model['flds']] + + + @webApi() + def modelFieldsOnTemplates(self, modelName): + model = self.collection().models.byName(modelName) + + if model is not None: + templates = {} + for template in model['tmpls']: + fields = [] + + for side in ['qfmt', 'afmt']: + fieldsForSide = [] + + # based on _fieldsOnTemplate from aqt/clayout.py + matches = re.findall('{{[^#/}]+?}}', template[side]) + for match in matches: + # remove braces and modifiers + match = re.sub(r'[{}]', '', match) + match = match.split(':')[-1] + + # for the answer side, ignore fields present on the question side + the FrontSide field + if match == 'FrontSide' or side == 'afmt' and match in fields[0]: + continue + fieldsForSide.append(match) + + + fields.append(fieldsForSide) + + templates[template['name']] = fields + + return templates + + + @webApi() + def getDeckConfig(self, deck): + if not deck in self.deckNames(): + return False + + did = self.collection().decks.id(deck) + return self.collection().decks.confForDid(did) + + + @webApi() + def saveDeckConfig(self, config): + configId = str(config['id']) + if not configId in self.collection().decks.dconf: + return False + + mod = anki.utils.intTime() + usn = self.collection().usn() + + config['mod'] = mod + config['usn'] = usn + + self.collection().decks.dconf[configId] = config + self.collection().decks.changed = True + return True + + + @webApi() + def setDeckConfigId(self, decks, configId): + for deck in decks: + if not deck in self.deckNames(): + return False + + if not str(configId) in self.collection().decks.dconf: + return False + + for deck in decks: + did = str(self.collection().decks.id(deck)) + aqt.mw.col.decks.decks[did]['conf'] = configId + + return True + + + @webApi() + def cloneDeckConfigId(self, name, cloneFrom=1): + if not str(cloneFrom) in self.collection().decks.dconf: + return False + + cloneFrom = self.collection().decks.getConf(cloneFrom) + return self.collection().decks.confId(name, cloneFrom) + + + @webApi() + def removeDeckConfigId(self, configId): + if configId == 1 or not str(configId) in self.collection().decks.dconf: + return False + + self.collection().decks.remConf(configId) + return True + + + @webApi() + def deckNames(self): + collection = self.collection() + if collection is not None: + return collection.decks.allNames() + + + @webApi() + def deckNamesAndIds(self): + decks = {} + + deckNames = self.deckNames() + for deck in deckNames: + did = self.collection().decks.id(deck) + decks[deck] = did + + return decks + + + @webApi() + def deckNameFromId(self, deckId): + collection = self.collection() + if collection is not None: + deck = collection.decks.get(deckId) + if deck is not None: + return deck['name'] + + + @webApi() + def findNotes(self, query=None): + if query is not None: + return self.collection().findNotes(query) + else: + return [] + + + @webApi() + def findCards(self, query=None): + if query is not None: + return self.collection().findCards(query) + else: + return [] + + + @webApi() + def cardsInfo(self, cards): + result = [] + for cid in cards: + try: + card = self.collection().getCard(cid) + model = card.model() + note = card.note() + fields = {} + for info in model['flds']: + order = info['ord'] + name = info['name'] + fields[name] = {'value': note.fields[order], 'order': order} + + result.append({ + 'cardId': card.id, + 'fields': fields, + 'fieldOrder': card.ord, + 'question': card._getQA()['q'], + 'answer': card._getQA()['a'], + 'modelName': model['name'], + 'deckName': self.deckNameFromId(card.did), + 'css': model['css'], + 'factor': card.factor, + #This factor is 10 times the ease percentage, + # so an ease of 310% would be reported as 3100 + 'interval': card.ivl, + 'note': card.nid + }) + except TypeError as e: + # Anki will give a TypeError if the card ID does not exist. + # Best behavior is probably to add an 'empty card' to the + # returned result, so that the items of the input and return + # lists correspond. + result.append({}) + + return result + + + @webApi() + def notesInfo(self, notes): + result = [] + for nid in notes: + try: + note = self.collection().getNote(nid) + model = note.model() + + fields = {} + for info in model['flds']: + order = info['ord'] + name = info['name'] + fields[name] = {'value': note.fields[order], 'order': order} + + result.append({ + 'noteId': note.id, + 'tags' : note.tags, + 'fields': fields, + 'modelName': model['name'], + 'cards': self.collection().db.list( + 'select id from cards where nid = ? order by ord', note.id) + }) + except TypeError as e: + # Anki will give a TypeError if the note ID does not exist. + # Best behavior is probably to add an 'empty card' to the + # returned result, so that the items of the input and return + # lists correspond. + result.append({}) + return result + + + @webApi() + def getDecks(self, cards): + decks = {} + for card in cards: + did = self.collection().db.scalar('select did from cards where id = ?', card) + deck = self.collection().decks.get(did)['name'] + + if deck in decks: + decks[deck].append(card) + else: + decks[deck] = [card] + + return decks + + + @webApi() + def createDeck(self, deck): + self.startEditing() + deckId = self.collection().decks.id(deck) + self.stopEditing() + + return deckId + + + @webApi() + def changeDeck(self, cards, deck): + self.startEditing() + + did = self.collection().decks.id(deck) + mod = anki.utils.intTime() + usn = self.collection().usn() + + # normal cards + scids = anki.utils.ids2str(cards) + # remove any cards from filtered deck first + self.collection().sched.remFromDyn(cards) + + # then move into new deck + self.collection().db.execute('update cards set usn=?, mod=?, did=? where id in ' + scids, usn, mod, did) + self.stopEditing() + + + @webApi() + def deleteDecks(self, decks, cardsToo=False): + self.startEditing() + for deck in decks: + did = self.collection().decks.id(deck) + self.collection().decks.rem(did, cardsToo) + self.stopEditing() + + + @webApi() + def cardsToNotes(self, cards): + return self.collection().db.list('select distinct nid from cards where id in ' + anki.utils.ids2str(cards)) + + + @webApi() + def guiBrowse(self, query=None): + browser = aqt.dialogs.open('Browser', self.window()) + browser.activateWindow() + + if query is not None: + browser.form.searchEdit.lineEdit().setText(query) + if hasattr(browser, 'onSearch'): + browser.onSearch() + else: + browser.onSearchActivated() + + return browser.model.cards + + + @webApi() + def guiAddCards(self): + addCards = aqt.dialogs.open('AddCards', self.window()) + addCards.activateWindow() + + + @webApi() + def guiReviewActive(self): + return self.reviewer().card is not None and self.window().state == 'review' + + + @webApi() + def guiCurrentCard(self): + if not self.guiReviewActive(): + raise Exception('Gui review is not currently active.') + + reviewer = self.reviewer() + card = reviewer.card + model = card.model() + note = card.note() + + fields = {} + for info in model['flds']: + order = info['ord'] + name = info['name'] + fields[name] = {'value': note.fields[order], 'order': order} + + if card is not None: + return { + 'cardId': card.id, + 'fields': fields, + 'fieldOrder': card.ord, + 'question': card._getQA()['q'], + 'answer': card._getQA()['a'], + 'buttons': [b[0] for b in reviewer._answerButtonList()], + 'modelName': model['name'], + 'deckName': self.deckNameFromId(card.did), + 'css': model['css'] + } + + + @webApi() + def guiStartCardTimer(self): + if not self.guiReviewActive(): + return False + + card = self.reviewer().card + + if card is not None: + card.startTimer() + return True + else: + return False + + + @webApi() + def guiShowQuestion(self): + if self.guiReviewActive(): + self.reviewer()._showQuestion() + return True + else: + return False + + + @webApi() + def guiShowAnswer(self): + if self.guiReviewActive(): + self.window().reviewer._showAnswer() + return True + else: + return False + + + @webApi() + def guiAnswerCard(self, ease): + if not self.guiReviewActive(): + return False + + reviewer = self.reviewer() + if reviewer.state != 'answer': + return False + if ease <= 0 or ease > self.scheduler().answerButtons(reviewer.card): + return False + + reviewer._answerCard(ease) + return True + + + @webApi() + def guiDeckOverview(self, name): + collection = self.collection() + if collection is not None: + deck = collection.decks.byName(name) + if deck is not None: + collection.decks.select(deck['id']) + self.window().onOverview() + return True + + return False + + + @webApi() + def guiDeckBrowser(self): + self.window().moveToState('deckBrowser') + + + @webApi() + def guiDeckReview(self, name): + if self.guiDeckOverview(name): + self.window().moveToState('review') + return True + else: + return False + + + @webApi() + def guiExitAnki(self): + timer = QTimer() + def exitAnki(): + timer.stop() + self.window().close() + timer.timeout.connect(exitAnki) + timer.start(1000) # 1s should be enough to allow the response to be sent. + + + @webApi() + def sync(self): + self.window().onSync() @webApi() def upgrade(self): response = QMessageBox.question( - self.anki.window(), + self.window(), 'AnkiConnect', 'Upgrade to the latest version?', QMessageBox.Yes | QMessageBox.No @@ -1213,12 +1118,12 @@ class AnkiConnect: if response == QMessageBox.Yes: data = download(URL_UPGRADE) if data is None: - QMessageBox.critical(self.anki.window(), 'AnkiConnect', 'Failed to download latest version.') + QMessageBox.critical(self.window(), 'AnkiConnect', 'Failed to download latest version.') else: path = os.path.splitext(__file__)[0] + '.py' with open(path, 'w') as fp: fp.write(makeStr(data)) - QMessageBox.information(self.anki.window(), 'AnkiConnect', 'Upgraded to the latest version, please restart Anki.') + QMessageBox.information(self.window(), 'AnkiConnect', 'Upgraded to the latest version, please restart Anki.') return True return False @@ -1230,108 +1135,24 @@ class AnkiConnect: @webApi() - def findNotes(self, query=None): - return self.anki.findNotes(query) + def addNotes(self, notes): + results = [] + for note in notes: + try: + results.append(self.addNote(params)) + except Exception: + results.append(None) + + return results @webApi() - def findCards(self, query=None): - return self.anki.findCards(query) + def canAddNotes(self, notes): + results = [] + for note in notes: + results.append(self.canAddNote(params)) - - @webApi() - def getDecks(self, cards): - return self.anki.getDecks(cards) - - - @webApi() - def createDeck(self, deck): - return self.anki.createDeck(deck) - - - @webApi() - def changeDeck(self, cards, deck): - return self.anki.changeDeck(cards, deck) - - - @webApi() - def deleteDecks(self, decks, cardsToo=False): - return self.anki.deleteDecks(decks, cardsToo) - - - @webApi() - def cardsToNotes(self, cards): - return self.anki.cardsToNotes(cards) - - - @webApi() - def guiBrowse(self, query=None): - return self.anki.guiBrowse(query) - - - @webApi() - def guiAddCards(self): - return self.anki.guiAddCards() - - - @webApi() - def guiCurrentCard(self): - return self.anki.guiCurrentCard() - - - @webApi() - def guiStartCardTimer(self): - return self.anki.guiStartCardTimer() - - - @webApi() - def guiAnswerCard(self, ease): - return self.anki.guiAnswerCard(ease) - - - @webApi() - def guiShowQuestion(self): - return self.anki.guiShowQuestion() - - - @webApi() - def guiShowAnswer(self): - return self.anki.guiShowAnswer() - - - @webApi() - def guiDeckOverview(self, name): - return self.anki.guiDeckOverview(name) - - - @webApi() - def guiDeckBrowser(self): - return self.anki.guiDeckBrowser() - - - @webApi() - def guiDeckReview(self, name): - return self.anki.guiDeckReview(name) - - - @webApi() - def guiExitAnki(self): - return self.anki.guiExitAnki() - - - @webApi() - def cardsInfo(self, cards): - return self.anki.cardsInfo(cards) - - - @webApi() - def notesInfo(self, notes): - return self.anki.notesInfo(notes) - - - @webApi() - def sync(self): - return self.anki.sync() + return results # From 4173f4e5fe97bdaa82be60ae6d46edefa15d8d2e Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Sun, 6 May 2018 13:24:40 -0700 Subject: [PATCH 02/25] fix incorrect variable usage --- AnkiConnect.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index 7f0f780..ccf4a11 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -538,7 +538,7 @@ class AnkiConnect: return False try: - return bool(self.createNote(note)) + return bool(self.createNote(params)) except: return False @@ -1139,7 +1139,7 @@ class AnkiConnect: results = [] for note in notes: try: - results.append(self.addNote(params)) + results.append(self.addNote(note)) except Exception: results.append(None) @@ -1150,7 +1150,7 @@ class AnkiConnect: def canAddNotes(self, notes): results = [] for note in notes: - results.append(self.canAddNote(params)) + results.append(self.canAddNote(note)) return results From e80bf2e8aa9f29cd3a776bf43c9b45e92a0a61cc Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Sun, 6 May 2018 17:52:24 -0700 Subject: [PATCH 03/25] work in progress on cleaning up api --- AnkiConnect.py | 602 +++++++++++++++++++++---------------------------- 1 file changed, 262 insertions(+), 340 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index ccf4a11..41a89b0 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -37,17 +37,17 @@ from unicodedata import normalize # Constants # -API_VERSION = 5 -NET_ADDRESS = os.getenv('ANKICONNECT_BIND_ADDRESS', '127.0.0.1') -NET_BACKLOG = 5 -NET_PORT = 8765 +API_VERSION = 5 +NET_ADDRESS = os.getenv('ANKICONNECT_BIND_ADDRESS', '127.0.0.1') +NET_BACKLOG = 5 +NET_PORT = 8765 TICK_INTERVAL = 25 -URL_TIMEOUT = 10 -URL_UPGRADE = 'https://raw.githubusercontent.com/FooSoft/anki-connect/master/AnkiConnect.py' +URL_TIMEOUT = 10 +URL_UPGRADE = 'https://raw.githubusercontent.com/FooSoft/anki-connect/master/AnkiConnect.py' # -# General helpers +# Helpers # if sys.version_info[0] < 3: @@ -66,19 +66,6 @@ else: from PyQt5.QtWidgets import QMessageBox -# -# Helpers -# - -def webApi(*versions): - def decorator(func): - method = lambda *args, **kwargs: func(*args, **kwargs) - setattr(method, 'versions', versions) - setattr(method, 'api', True) - return method - return decorator - - def makeBytes(data): return data.encode('utf-8') @@ -87,35 +74,14 @@ def makeStr(data): return data.decode('utf-8') -def download(url): - try: - resp = web.urlopen(url, timeout=URL_TIMEOUT) - except web.URLError as e: - raise Exception('A urlError has occurred for url ' + url + '. Error messages was: ' + e.message) +def api(*versions): + def decorator(func): + method = lambda *args, **kwargs: func(*args, **kwargs) + setattr(method, 'versions', versions) + setattr(method, 'api', True) + return method - if resp.code != 200: - raise Exception('Return code for url request' + url + 'was not 200. Error code: ' + resp.code) - - return resp.read() - - -def audioInject(note, fields, filename): - for field in fields: - if field in note: - note[field] += u'[sound:{}]'.format(filename) - - -def verifyString(string): - t = type(string) - return t == str or t == unicode - - -def verifyStringList(strings): - for s in strings: - if not verifyString(s): - return False - - return True + return decorator # @@ -212,7 +178,7 @@ class WebServer: def setHeader(self, name, value): - self.extraHeaders[name] = value + self.headersOpt[name] = value def resetHeaders(self): @@ -221,13 +187,14 @@ class WebServer: ['Content-Type', 'text/json'], ['Access-Control-Allow-Origin', '*'] ] - self.extraHeaders = {} + self.headersOpt = {} def getHeaders(self): headers = self.headers[:] - for name in self.extraHeaders: - headers.append([name, self.extraHeaders[name]]) + for name in self.headersOpt: + headers.append([name, self.headersOpt[name]]) + return headers @@ -301,49 +268,7 @@ class WebServer: # -# AnkiNoteParams -# - -class AnkiNoteParams: - def __init__(self, params): - self.deckName = params.get('deckName') - self.modelName = params.get('modelName') - self.fields = params.get('fields', {}) - self.tags = params.get('tags', []) - - class Audio: - def __init__(self, params): - self.url = params.get('url') - self.filename = params.get('filename') - self.skipHash = params.get('skipHash') - self.fields = params.get('fields', []) - - def validate(self): - return ( - verifyString(self.url) and - verifyString(self.filename) and os.path.dirname(self.filename) == '' and - verifyStringList(self.fields) and - (verifyString(self.skipHash) or self.skipHash is None) - ) - - audio = Audio(params.get('audio', {})) - self.audio = audio if audio.validate() else None - - - def validate(self): - return ( - verifyString(self.deckName) and - verifyString(self.modelName) and - type(self.fields) == dict and verifyStringList(list(self.fields.keys())) and verifyStringList(list(self.fields.values())) and - type(self.tags) == list and verifyStringList(self.tags) - ) - - - def __str__(self): - return 'DeckName: ' + self.deckName + '. ModelName: ' + self.modelName + '. Fields: ' + str(self.fields) + '. Tags: ' + str(self.tags) + '.' - -# -# AnkiBridge +# AnkiConnect # class AnkiConnect: @@ -407,6 +332,50 @@ class AnkiConnect: return reply['result'] + def download(self, url): + resp = web.urlopen(url, timeout=URL_TIMEOUT) + if resp.code == 200: + return resp.read() + else: + raise Exception('return code for download of {} was {}'.format(url, resp.code)) + + + def window(self): + return aqt.mw + + + def reviewer(self): + reviewer = self.window().reviewer + if reviewer is None: + raise Exception('reviewer is not available') + else: + return reviewer + + + def collection(self): + collection = self.window().col + if collection is None: + raise Exception('collection is not available') + else: + return collection + + + def scheduler(self): + scheduler = self.collection().sched + if scheduler is None: + raise Exception('scheduler is not available') + else: + return scheduler + + + def media(self): + media = self.collection().media + if media is None: + raise Exception('media is not available') + else: + return media + + def startEditing(self): self.window().requireReset() @@ -416,68 +385,44 @@ class AnkiConnect: self.window().maybeReset() - def window(self): - return aqt.mw - - - def reviewer(self): - return self.window().reviewer - - - def collection(self): - return self.window().col - - - def scheduler(self): - return self.collection().sched - - - def media(self): + def createNote(self, note): collection = self.collection() - if collection is not None: - return collection.media - - def createNote(self, params): - collection = self.collection() - if collection is None: - raise Exception('Collection was not found.') - - model = collection.models.byName(params.modelName) + model = collection.models.byName(note['modelName']) if model is None: - raise Exception('Model was not found for model: ' + params.modelName) + raise Exception('model was not found: {}'.format(note['modelName'])) - deck = collection.decks.byName(params.deckName) + deck = collection.decks.byName(note['deckName']) if deck is None: - raise Exception('Deck was not found for deck: ' + params.deckName) + raise Exception('deck was not found: {}'.format(note['deckName'])) - note = anki.notes.Note(collection, model) - note.model()['did'] = deck['id'] - note.tags = params.tags + ankiNote = anki.notes.Note(collection, model) + ankiNote.model()['did'] = deck['id'] + ankiNote.tags = note['tags'] - for name, value in params.fields.items(): - if name in note: - note[name] = value + for name, value in note['fields'].items(): + if name in ankiNote: + ankiNote[name] = value - # Returns 1 if empty. 2 if duplicate. Otherwise returns False - duplicateOrEmpty = note.dupeOrEmpty() + duplicateOrEmpty = ankiNote.dupeOrEmpty() if duplicateOrEmpty == 1: - raise Exception('Note was empty. Param were: ' + str(params)) + raise Exception('cannot create note because it is empty') elif duplicateOrEmpty == 2: - raise Exception('Note is duplicate of existing note. Params were: ' + str(params)) + raise Exception('cannot create note because it is a duplicte') elif duplicateOrEmpty == False: - return note + return ankiNote + else: + raise Exception('cannot create note for unknown reason') - @webApi() + @api() def storeMediaFile(self, filename, data): self.deleteMediaFile(filename) self.media().writeData(filename, base64.b64decode(data)) - @webApi() + @api() def retrieveMediaFile(self, filename): - # based on writeData from anki/media.py filename = os.path.basename(filename) filename = normalize('NFC', filename) filename = self.media().stripIllegal(filename) @@ -490,352 +435,328 @@ class AnkiConnect: return False - @webApi() + @api() def deleteMediaFile(self, filename): self.media().syncDelete(filename) - @webApi() + @api() def addNote(self, note): - params = AnkiNoteParams(note) - if not params.validate(): - raise Exception('Invalid note parameters') + ankiNote = self.createNote(note) - collection = self.collection() - if collection is None: - raise Exception('Collection was not found.') - - note = self.createNote(params) - if note is None: - raise Exception('Failed to create note from params: ' + str(params)) - - if params.audio is not None and len(params.audio.fields) > 0: - data = download(params.audio.url) + if note['audio'] is not None and len(note['audio']['fields']) > 0: + audio = note['audio'] + data = download(audio['url']) if data is not None: - if params.audio.skipHash is None: + if audio['skipHash'] is None: skip = False else: m = hashlib.md5() m.update(data) - skip = params.audio.skipHash == m.hexdigest() + skip = audio['skipHash'] == m.hexdigest() if not skip: - audioInject(note, params.audio.fields, params.audio.filename) - self.media().writeData(params.audio.filename, data) + for field in audio['fields']: + if field in ankiNote: + ankiNote[field] += u'[sound:{}]'.format(audio['filename']) + self.media().writeData(audio['filename'], data) + + collection = self.collection() self.startEditing() - collection.addNote(note) + collection.addNote(ankiNote) collection.autosave() self.stopEditing() - return note.id + return ankiNote.id - @webApi() + @api() def canAddNote(self, note): - params = AnkiNoteParams(note) - if not params.validate(): - return False - try: - return bool(self.createNote(params)) + return bool(self.createNote(note)) except: return False - @webApi() + @api() def updateNoteFields(self, params): - collection = self.collection() - if collection is None: - raise Exception('Collection was not found.') - - note = collection.getNote(params['id']) + note = self.collection().getNote(params['id']) if note is None: - raise Exception('Failed to get note:{}'.format(params['id'])) + raise Exception('note was not found: {}'.format(params['id'])) + for name, value in params['fields'].items(): if name in note: note[name] = value + note.flush() - @webApi() + @api() def addTags(self, notes, tags, add=True): self.startEditing() self.collection().tags.bulkAdd(notes, tags, add) self.stopEditing() - @webApi() + @api() def removeTags(self, notes, tags): return self.addTags(notes, tags, False) - @webApi() + @api() def getTags(self): return self.collection().tags.all() - @webApi() + @api() def suspend(self, cards, suspend=True): for card in cards: - isSuspended = self.isSuspended(card) - if suspend and isSuspended: - cards.remove(card) - elif not suspend and not isSuspended: + if self.suspended(card) == suspend: cards.remove(card) - if cards: - self.startEditing() - if suspend: - self.collection().sched.suspendCards(cards) - else: - self.collection().sched.unsuspendCards(cards) - self.stopEditing() - return True + if len(cards) == 0: + return False - return False + scheduler = self.scheduler() + self.startEditing() + if suspend: + scheduler.suspendCards(cards) + else: + scheduler.unsuspendCards(cards) + self.stopEditing() + + return True - @webApi() + @api() def unsuspend(self, cards): self.suspend(cards, False) - @webApi() - def isSuspended(self, card): + @api() + def suspended(self, card): card = self.collection().getCard(card) return card.queue == -1 - @webApi() + @api() def areSuspended(self, cards): suspended = [] for card in cards: - suspended.append(self.isSuspended(card)) + suspended.append(self.suspended(card)) + return suspended - @webApi() + @api() def areDue(self, cards): due = [] for card in cards: - if self.findCards('cid:%s is:new' % card): + if self.findCards('cid:{} is:new'.format(card)): due.append(True) - continue - - date, ivl = self.collection().db.all('select id/1000.0, ivl from revlog where cid = ?', card)[-1] - if (ivl >= -1200): - if self.findCards('cid:%s is:due' % card): - due.append(True) - else: - due.append(False) else: - if date - ivl <= time(): - due.append(True) + date, ivl = self.collection().db.all('select id/1000.0, ivl from revlog where cid = ?', card)[-1] + if ivl >= -1200: + duo.append(bool(self.findCards('cid:{} is:due'.format(card)))) else: - due.append(False) + due.append(date - ivl <= time()) return due - @webApi() + @api() def getIntervals(self, cards, complete=False): intervals = [] for card in cards: - if self.findCards('cid:%s is:new' % card): + if self.findCards('cid:{} is:new'.format(card)): intervals.append(0) - continue + else: + interval = self.collection().db.list('select ivl from revlog where cid = ?', card) + if not complete: + interval = interval[-1] + intervals.append(interval) - interval = self.collection().db.list('select ivl from revlog where cid = ?', card) - if not complete: - interval = interval[-1] - intervals.append(interval) return intervals - @webApi() + @api() def multi(self, actions): response = [] for item in actions: response.append(self.handler(item)) + return response - @webApi() + @api() def modelNames(self): - collection = self.collection() - if collection is not None: - return collection.models.allNames() + return self.collection().models.allNames() - @webApi() + @api() def modelNamesAndIds(self): models = {} - - modelNames = self.modelNames() - for model in modelNames: - mid = self.collection().models.byName(model)['id'] - mid = int(mid) # sometimes Anki stores the ID as a string - models[model] = mid + for model in self.modelNames(): + models[model] = int(self.collection().models.byName(model)['id']) return models - @webApi() + @api() def modelNameFromId(self, modelId): - collection = self.collection() - if collection is not None: - model = collection.models.get(modelId) - if model is not None: - return model['name'] + model = self.collection().models.get(modelId) + if model is None: + raise Exception('model was not found: {}'.format(modelId)) + else: + return model['name'] - @webApi() + @api() def modelFieldNames(self, modelName): - collection = self.collection() - if collection is not None: - model = collection.models.byName(modelName) - if model is not None: - return [field['name'] for field in model['flds']] + model = self.collection().models.byName(modelName) + if model is None: + raise Exception('model was not found: {}'.format(modelName)) + else: + return [field['name'] for field in model['flds']] - @webApi() + @api() def modelFieldsOnTemplates(self, modelName): model = self.collection().models.byName(modelName) + if model is None: + raise Exception('model was not found: {}'.format(modelName)) - if model is not None: - templates = {} - for template in model['tmpls']: - fields = [] + templates = {} + for template in model['tmpls']: + fields = [] + for side in ['qfmt', 'afmt']: + fieldsForSide = [] - for side in ['qfmt', 'afmt']: - fieldsForSide = [] + # based on _fieldsOnTemplate from aqt/clayout.py + matches = re.findall('{{[^#/}]+?}}', template[side]) + for match in matches: + # remove braces and modifiers + match = re.sub(r'[{}]', '', match) + match = match.split(':')[-1] - # based on _fieldsOnTemplate from aqt/clayout.py - matches = re.findall('{{[^#/}]+?}}', template[side]) - for match in matches: - # remove braces and modifiers - match = re.sub(r'[{}]', '', match) - match = match.split(':')[-1] + # for the answer side, ignore fields present on the question side + the FrontSide field + if match == 'FrontSide' or side == 'afmt' and match in fields[0]: + continue + fieldsForSide.append(match) - # for the answer side, ignore fields present on the question side + the FrontSide field - if match == 'FrontSide' or side == 'afmt' and match in fields[0]: - continue - fieldsForSide.append(match) + fields.append(fieldsForSide) + + templates[template['name']] = fields + + return templates - fields.append(fieldsForSide) - - templates[template['name']] = fields - - return templates - - - @webApi() + @api() def getDeckConfig(self, deck): if not deck in self.deckNames(): return False - did = self.collection().decks.id(deck) - return self.collection().decks.confForDid(did) + collection = self.collection() + did = collection.decks.id(deck) + return collection.decks.confForDid(did) - @webApi() + @api() def saveDeckConfig(self, config): - configId = str(config['id']) - if not configId in self.collection().decks.dconf: + collection = self.collection() + + config['id'] = str(config['id']) + config['mod'] = anki.utils.intTime() + config['usn'] = collection.usn() + + if not config['id'] in collection.decks.dconf: return False - mod = anki.utils.intTime() - usn = self.collection().usn() - - config['mod'] = mod - config['usn'] = usn - - self.collection().decks.dconf[configId] = config - self.collection().decks.changed = True + collection.decks.dconf[config['id']] = config + collection.decks.changed = True return True - @webApi() + @api() def setDeckConfigId(self, decks, configId): + configId = str(configId) for deck in decks: if not deck in self.deckNames(): return False - if not str(configId) in self.collection().decks.dconf: + collection = self.collection() + if not configId in collection.decks.dconf: return False for deck in decks: - did = str(self.collection().decks.id(deck)) + did = str(collection.decks.id(deck)) aqt.mw.col.decks.decks[did]['conf'] = configId return True - @webApi() - def cloneDeckConfigId(self, name, cloneFrom=1): - if not str(cloneFrom) in self.collection().decks.dconf: + @api() + def cloneDeckConfigId(self, name, cloneFrom='1'): + configId = str(cloneFrom) + if not configId in self.collection().decks.dconf: return False - cloneFrom = self.collection().decks.getConf(cloneFrom) - return self.collection().decks.confId(name, cloneFrom) + config = self.collection().decks.getConf(configId) + return self.collection().decks.confId(name, config) - @webApi() + @api() def removeDeckConfigId(self, configId): - if configId == 1 or not str(configId) in self.collection().decks.dconf: + configId = str(configId) + collection = self.collection() + if configId == 1 or not configId in collection.decks.dconf: return False - self.collection().decks.remConf(configId) + collection.decks.remConf(configId) return True - @webApi() + @api() def deckNames(self): - collection = self.collection() - if collection is not None: - return collection.decks.allNames() + return self.collection().decks.allNames() - @webApi() + @api() def deckNamesAndIds(self): decks = {} - - deckNames = self.deckNames() - for deck in deckNames: - did = self.collection().decks.id(deck) - decks[deck] = did + for deck in self.deckNames(): + decks[deck] = self.collection().decks.id(deck) return decks - @webApi() + @api() def deckNameFromId(self, deckId): - collection = self.collection() - if collection is not None: - deck = collection.decks.get(deckId) - if deck is not None: - return deck['name'] + deck = self.collection().decks.get(deckId) + if deck is None: + raise Exception('deck was not found: {}'.format(deckId)) + else: + return deck['name'] - @webApi() + @api() def findNotes(self, query=None): - if query is not None: + if query is None: + return [] + else: return self.collection().findNotes(query) - else: - return [] - @webApi() + @api() def findCards(self, query=None): - if query is not None: - return self.collection().findCards(query) - else: + if query not None: return [] + else: + return self.collection().findCards(query) - @webApi() + @api() def cardsInfo(self, cards): result = [] for cid in cards: @@ -874,7 +795,7 @@ class AnkiConnect: return result - @webApi() + @api() def notesInfo(self, notes): result = [] for nid in notes: @@ -893,8 +814,7 @@ class AnkiConnect: 'tags' : note.tags, 'fields': fields, 'modelName': model['name'], - 'cards': self.collection().db.list( - 'select id from cards where nid = ? order by ord', note.id) + 'cards': self.collection().db.list('select id from cards where nid = ? order by ord', note.id) }) except TypeError as e: # Anki will give a TypeError if the note ID does not exist. @@ -902,15 +822,17 @@ class AnkiConnect: # returned result, so that the items of the input and return # lists correspond. result.append({}) + return result - @webApi() + @api() def getDecks(self, cards): decks = {} + collection = self.collection() for card in cards: - did = self.collection().db.scalar('select did from cards where id = ?', card) - deck = self.collection().decks.get(did)['name'] + did = collection.db.scalar('select did from cards where id = ?', card) + deck = collection.decks.get(did)['name'] if deck in decks: decks[deck].append(card) @@ -920,7 +842,7 @@ class AnkiConnect: return decks - @webApi() + @api() def createDeck(self, deck): self.startEditing() deckId = self.collection().decks.id(deck) @@ -929,7 +851,7 @@ class AnkiConnect: return deckId - @webApi() + @api() def changeDeck(self, cards, deck): self.startEditing() @@ -947,7 +869,7 @@ class AnkiConnect: self.stopEditing() - @webApi() + @api() def deleteDecks(self, decks, cardsToo=False): self.startEditing() for deck in decks: @@ -956,12 +878,12 @@ class AnkiConnect: self.stopEditing() - @webApi() + @api() def cardsToNotes(self, cards): return self.collection().db.list('select distinct nid from cards where id in ' + anki.utils.ids2str(cards)) - @webApi() + @api() def guiBrowse(self, query=None): browser = aqt.dialogs.open('Browser', self.window()) browser.activateWindow() @@ -976,18 +898,18 @@ class AnkiConnect: return browser.model.cards - @webApi() + @api() def guiAddCards(self): addCards = aqt.dialogs.open('AddCards', self.window()) addCards.activateWindow() - @webApi() + @api() def guiReviewActive(self): return self.reviewer().card is not None and self.window().state == 'review' - @webApi() + @api() def guiCurrentCard(self): if not self.guiReviewActive(): raise Exception('Gui review is not currently active.') @@ -1017,7 +939,7 @@ class AnkiConnect: } - @webApi() + @api() def guiStartCardTimer(self): if not self.guiReviewActive(): return False @@ -1031,7 +953,7 @@ class AnkiConnect: return False - @webApi() + @api() def guiShowQuestion(self): if self.guiReviewActive(): self.reviewer()._showQuestion() @@ -1040,7 +962,7 @@ class AnkiConnect: return False - @webApi() + @api() def guiShowAnswer(self): if self.guiReviewActive(): self.window().reviewer._showAnswer() @@ -1049,7 +971,7 @@ class AnkiConnect: return False - @webApi() + @api() def guiAnswerCard(self, ease): if not self.guiReviewActive(): return False @@ -1064,7 +986,7 @@ class AnkiConnect: return True - @webApi() + @api() def guiDeckOverview(self, name): collection = self.collection() if collection is not None: @@ -1077,12 +999,12 @@ class AnkiConnect: return False - @webApi() + @api() def guiDeckBrowser(self): self.window().moveToState('deckBrowser') - @webApi() + @api() def guiDeckReview(self, name): if self.guiDeckOverview(name): self.window().moveToState('review') @@ -1091,7 +1013,7 @@ class AnkiConnect: return False - @webApi() + @api() def guiExitAnki(self): timer = QTimer() def exitAnki(): @@ -1101,12 +1023,12 @@ class AnkiConnect: timer.start(1000) # 1s should be enough to allow the response to be sent. - @webApi() + @api() def sync(self): self.window().onSync() - @webApi() + @api() def upgrade(self): response = QMessageBox.question( self.window(), @@ -1129,12 +1051,12 @@ class AnkiConnect: return False - @webApi() + @api() def version(self): return API_VERSION - @webApi() + @api() def addNotes(self, notes): results = [] for note in notes: @@ -1146,7 +1068,7 @@ class AnkiConnect: return results - @webApi() + @api() def canAddNotes(self, notes): results = [] for note in notes: @@ -1156,7 +1078,7 @@ class AnkiConnect: # -# Entry +# Entry # ac = AnkiConnect() From fb37ac9f8a70ada48a3a499bc5365869850f87ec Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Sun, 6 May 2018 18:45:56 -0700 Subject: [PATCH 04/25] wip --- AnkiConnect.py | 91 +++++++++++++++++++++++++--------------------- tests/test_misc.py | 41 ++++++++++++++++++--- tests/util.py | 24 ++++++++---- 3 files changed, 101 insertions(+), 55 deletions(-) mode change 100644 => 100755 tests/test_misc.py diff --git a/AnkiConnect.py b/AnkiConnect.py index 41a89b0..96076e7 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -414,6 +414,55 @@ class AnkiConnect: else: raise Exception('cannot create note for unknown reason') + # + # Miscellaneous + # + + @api() + def version(self): + return API_VERSION + + + @api() + def upgrade(self): + response = QMessageBox.question( + self.window(), + 'AnkiConnect', + 'Upgrade to the latest version?', + QMessageBox.Yes | QMessageBox.No + ) + + if response == QMessageBox.Yes: + try: + data = download(URL_UPGRADE) + path = os.path.splitext(__file__)[0] + '.py' + with open(path, 'w') as fp: + fp.write(makeStr(data)) + QMessageBox.information( + self.window(), + 'AnkiConnect', + 'Upgraded to the latest version, please restart Anki.' + ) + return True + except: + QMessageBox.critical(self.window(), 'AnkiConnect', 'Failed to download latest version.') + + return False + + + @api() + def sync(self): + print self.window().onSync() + + + @api() + def multi(self, actions): + response = [] + for item in actions: + response.append(self.handler(item)) + + return response + @api() def storeMediaFile(self, filename, data): @@ -580,14 +629,6 @@ class AnkiConnect: return intervals - @api() - def multi(self, actions): - response = [] - for item in actions: - response.append(self.handler(item)) - - return response - @api() def modelNames(self): @@ -750,7 +791,7 @@ class AnkiConnect: @api() def findCards(self, query=None): - if query not None: + if query is None: return [] else: return self.collection().findCards(query) @@ -1023,38 +1064,6 @@ class AnkiConnect: timer.start(1000) # 1s should be enough to allow the response to be sent. - @api() - def sync(self): - self.window().onSync() - - - @api() - def upgrade(self): - response = QMessageBox.question( - self.window(), - 'AnkiConnect', - 'Upgrade to the latest version?', - QMessageBox.Yes | QMessageBox.No - ) - - if response == QMessageBox.Yes: - data = download(URL_UPGRADE) - if data is None: - QMessageBox.critical(self.window(), 'AnkiConnect', 'Failed to download latest version.') - else: - path = os.path.splitext(__file__)[0] + '.py' - with open(path, 'w') as fp: - fp.write(makeStr(data)) - QMessageBox.information(self.window(), 'AnkiConnect', 'Upgraded to the latest version, please restart Anki.') - return True - - return False - - - @api() - def version(self): - return API_VERSION - @api() def addNotes(self, notes): diff --git a/tests/test_misc.py b/tests/test_misc.py old mode 100644 new mode 100755 index 11c8651..a88a8c9 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,10 +1,39 @@ -# -*- coding: utf-8 -*- +#!/usr/bin/env python + import unittest -from unittest import TestCase -from util import callAnkiConnectEndpoint +import util -class TestVersion(TestCase): +class TestVersion(unittest.TestCase): def test_version(self): - response = callAnkiConnectEndpoint({'action': 'version'}) - self.assertEqual(5, response) + result = util.invokeNoError('version') + self.assertEqual(result, 5) + + + def test_upgrade(self): + util.invokeNoError('upgrade') + + + def test_sync(self): + util.invokeNoError('sync') + + + def test_multi(self): + result = util.invokeNoError( + 'multi', { + 'actions': [ + util.request('version'), + util.request('version'), + util.request('version') + ] + } + ) + + self.assertEqual(len(result), 3) + for response in result: + self.assertIsNone(response['error']) + self.assertEqual(response['result'], 5) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/util.py b/tests/util.py index bf121a0..ba6d1d1 100644 --- a/tests/util.py +++ b/tests/util.py @@ -1,11 +1,19 @@ import json -import urllib import urllib2 -def callAnkiConnectEndpoint(data): - url = 'http://docker:8888' - dumpedData = json.dumps(data) - req = urllib2.Request(url, dumpedData) - response = urllib2.urlopen(req).read() - responseData = json.loads(response) - return responseData \ No newline at end of file + +def request(action, params={}, version=5): + return {'action': action, 'params': params, 'version': version} + + +def invoke(action, params={}, version=5, url='http://localhost:8765'): + requestJson = json.dumps(request(action, params, version)) + response = json.load(urllib2.urlopen(urllib2.Request(url, requestJson))) + return response['result'], response['error'] + + +def invokeNoError(action, params={}, version=5, url='http://localhost:8765'): + result, error = invoke(action, params, version, url) + if error is not None: + raise Exception(error) + return result From d33b4e0dc75236719d4f7efe0fd92e2a10a04959 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Sun, 6 May 2018 19:01:24 -0700 Subject: [PATCH 05/25] wip --- tests/test_misc.py | 10 +++++----- tests/util.py | 17 +++++++++-------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/tests/test_misc.py b/tests/test_misc.py index a88a8c9..6e2dbb3 100755 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -4,22 +4,22 @@ import unittest import util -class TestVersion(unittest.TestCase): +class TestMisc(unittest.TestCase): def test_version(self): - result = util.invokeNoError('version') + result = util.invoke('version') self.assertEqual(result, 5) def test_upgrade(self): - util.invokeNoError('upgrade') + util.invoke('upgrade') def test_sync(self): - util.invokeNoError('sync') + util.invoke('sync') def test_multi(self): - result = util.invokeNoError( + result = util.invoke( 'multi', { 'actions': [ util.request('version'), diff --git a/tests/util.py b/tests/util.py index ba6d1d1..2657867 100644 --- a/tests/util.py +++ b/tests/util.py @@ -9,11 +9,12 @@ def request(action, params={}, version=5): def invoke(action, params={}, version=5, url='http://localhost:8765'): requestJson = json.dumps(request(action, params, version)) response = json.load(urllib2.urlopen(urllib2.Request(url, requestJson))) - return response['result'], response['error'] - - -def invokeNoError(action, params={}, version=5, url='http://localhost:8765'): - result, error = invoke(action, params, version, url) - if error is not None: - raise Exception(error) - return result + if len(response) != 2: + raise Exception('response has an unexpected number of fields') + if 'error' not in response: + raise Exception('response is missing required error field') + if 'result' not in response: + raise Exception('response is missing required result field') + if response['error'] is not None: + raise Exception(response['error']) + return response['result'] From 570350b0383d5d3085c3ff48ae15d797ffcb5b4b Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Sun, 6 May 2018 19:04:53 -0700 Subject: [PATCH 06/25] fix upgrade code --- AnkiConnect.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index 96076e7..29657ce 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -434,7 +434,7 @@ class AnkiConnect: if response == QMessageBox.Yes: try: - data = download(URL_UPGRADE) + data = self.download(URL_UPGRADE) path = os.path.splitext(__file__)[0] + '.py' with open(path, 'w') as fp: fp.write(makeStr(data)) @@ -444,8 +444,9 @@ class AnkiConnect: 'Upgraded to the latest version, please restart Anki.' ) return True - except: + except Exception as e: QMessageBox.critical(self.window(), 'AnkiConnect', 'Failed to download latest version.') + raise e return False From 8b78a4d2f72a416f60bd078eda74999e7b11a30d Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Sun, 6 May 2018 19:11:54 -0700 Subject: [PATCH 07/25] wip --- AnkiConnect.py | 264 +++++++++++++++++++++++++------------------------ 1 file changed, 136 insertions(+), 128 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index 29657ce..3ede0e5 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -414,6 +414,7 @@ class AnkiConnect: else: raise Exception('cannot create note for unknown reason') + # # Miscellaneous # @@ -465,6 +466,141 @@ class AnkiConnect: return response + # + # Decks + # + + @api() + def deckNames(self): + return self.collection().decks.allNames() + + + @api() + def deckNamesAndIds(self): + decks = {} + for deck in self.deckNames(): + decks[deck] = self.collection().decks.id(deck) + + return decks + + + @api() + def getDecks(self, cards): + decks = {} + collection = self.collection() + for card in cards: + did = collection.db.scalar('select did from cards where id = ?', card) + deck = collection.decks.get(did)['name'] + + if deck in decks: + decks[deck].append(card) + else: + decks[deck] = [card] + + return decks + + + @api() + def createDeck(self, deck): + self.startEditing() + deckId = self.collection().decks.id(deck) + self.stopEditing() + + return deckId + + + @api() + def changeDeck(self, cards, deck): + self.startEditing() + + did = self.collection().decks.id(deck) + mod = anki.utils.intTime() + usn = self.collection().usn() + + # normal cards + scids = anki.utils.ids2str(cards) + # remove any cards from filtered deck first + self.collection().sched.remFromDyn(cards) + + # then move into new deck + self.collection().db.execute('update cards set usn=?, mod=?, did=? where id in ' + scids, usn, mod, did) + self.stopEditing() + + + @api() + def deleteDecks(self, decks, cardsToo=False): + self.startEditing() + for deck in decks: + did = self.collection().decks.id(deck) + self.collection().decks.rem(did, cardsToo) + self.stopEditing() + + + @api() + def getDeckConfig(self, deck): + if not deck in self.deckNames(): + return False + + collection = self.collection() + did = collection.decks.id(deck) + return collection.decks.confForDid(did) + + + @api() + def saveDeckConfig(self, config): + collection = self.collection() + + config['id'] = str(config['id']) + config['mod'] = anki.utils.intTime() + config['usn'] = collection.usn() + + if not config['id'] in collection.decks.dconf: + return False + + collection.decks.dconf[config['id']] = config + collection.decks.changed = True + return True + + + @api() + def setDeckConfigId(self, decks, configId): + configId = str(configId) + for deck in decks: + if not deck in self.deckNames(): + return False + + collection = self.collection() + if not configId in collection.decks.dconf: + return False + + for deck in decks: + did = str(collection.decks.id(deck)) + aqt.mw.col.decks.decks[did]['conf'] = configId + + return True + + + @api() + def cloneDeckConfigId(self, name, cloneFrom='1'): + configId = str(cloneFrom) + if not configId in self.collection().decks.dconf: + return False + + config = self.collection().decks.getConf(configId) + return self.collection().decks.confId(name, config) + + + @api() + def removeDeckConfigId(self, configId): + configId = str(configId) + collection = self.collection() + if configId == 1 or not configId in collection.decks.dconf: + return False + + collection.decks.remConf(configId) + return True + + @api() def storeMediaFile(self, filename, data): self.deleteMediaFile(filename) @@ -694,85 +830,6 @@ class AnkiConnect: return templates - @api() - def getDeckConfig(self, deck): - if not deck in self.deckNames(): - return False - - collection = self.collection() - did = collection.decks.id(deck) - return collection.decks.confForDid(did) - - - @api() - def saveDeckConfig(self, config): - collection = self.collection() - - config['id'] = str(config['id']) - config['mod'] = anki.utils.intTime() - config['usn'] = collection.usn() - - if not config['id'] in collection.decks.dconf: - return False - - collection.decks.dconf[config['id']] = config - collection.decks.changed = True - return True - - - @api() - def setDeckConfigId(self, decks, configId): - configId = str(configId) - for deck in decks: - if not deck in self.deckNames(): - return False - - collection = self.collection() - if not configId in collection.decks.dconf: - return False - - for deck in decks: - did = str(collection.decks.id(deck)) - aqt.mw.col.decks.decks[did]['conf'] = configId - - return True - - - @api() - def cloneDeckConfigId(self, name, cloneFrom='1'): - configId = str(cloneFrom) - if not configId in self.collection().decks.dconf: - return False - - config = self.collection().decks.getConf(configId) - return self.collection().decks.confId(name, config) - - - @api() - def removeDeckConfigId(self, configId): - configId = str(configId) - collection = self.collection() - if configId == 1 or not configId in collection.decks.dconf: - return False - - collection.decks.remConf(configId) - return True - - - @api() - def deckNames(self): - return self.collection().decks.allNames() - - - @api() - def deckNamesAndIds(self): - decks = {} - for deck in self.deckNames(): - decks[deck] = self.collection().decks.id(deck) - - return decks - - @api() def deckNameFromId(self, deckId): deck = self.collection().decks.get(deckId) @@ -868,56 +925,7 @@ class AnkiConnect: return result - @api() - def getDecks(self, cards): - decks = {} - collection = self.collection() - for card in cards: - did = collection.db.scalar('select did from cards where id = ?', card) - deck = collection.decks.get(did)['name'] - if deck in decks: - decks[deck].append(card) - else: - decks[deck] = [card] - - return decks - - - @api() - def createDeck(self, deck): - self.startEditing() - deckId = self.collection().decks.id(deck) - self.stopEditing() - - return deckId - - - @api() - def changeDeck(self, cards, deck): - self.startEditing() - - did = self.collection().decks.id(deck) - mod = anki.utils.intTime() - usn = self.collection().usn() - - # normal cards - scids = anki.utils.ids2str(cards) - # remove any cards from filtered deck first - self.collection().sched.remFromDyn(cards) - - # then move into new deck - self.collection().db.execute('update cards set usn=?, mod=?, did=? where id in ' + scids, usn, mod, did) - self.stopEditing() - - - @api() - def deleteDecks(self, decks, cardsToo=False): - self.startEditing() - for deck in decks: - did = self.collection().decks.id(deck) - self.collection().decks.rem(did, cardsToo) - self.stopEditing() @api() From acad72732fab2accdf97e4b7ca8aa95a023a718e Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Sun, 6 May 2018 20:21:59 -0700 Subject: [PATCH 08/25] work on tests --- test.sh | 3 +++ tests/test_decks.py | 16 ---------------- tests/test_misc.py | 13 ++++++++----- 3 files changed, 11 insertions(+), 21 deletions(-) create mode 100755 test.sh delete mode 100644 tests/test_decks.py diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..1fa881b --- /dev/null +++ b/test.sh @@ -0,0 +1,3 @@ +#!/usr/bin/sh + +python -m unittest discover -v tests diff --git a/tests/test_decks.py b/tests/test_decks.py deleted file mode 100644 index f4a79e8..0000000 --- a/tests/test_decks.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -import unittest -from unittest import TestCase -from util import callAnkiConnectEndpoint - -class TestDeckNames(TestCase): - - def test_deckNames(self): - response = callAnkiConnectEndpoint({'action': 'deckNames'}) - self.assertEqual(['Default'], response) - -class TestGetDeckConfig(TestCase): - - def test_getDeckConfig(self): - response = callAnkiConnectEndpoint({'action': 'getDeckConfig', 'params': {'deck': 'Default'}}) - self.assertDictContainsSubset({'name': 'Default', 'replayq': True}, response) \ No newline at end of file diff --git a/tests/test_misc.py b/tests/test_misc.py index 6e2dbb3..9de5d9e 100755 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -4,21 +4,24 @@ import unittest import util -class TestMisc(unittest.TestCase): - def test_version(self): +class TestVersion(unittest.TestCase): + def runTest(self): result = util.invoke('version') self.assertEqual(result, 5) - def test_upgrade(self): +class TestUpgrade(unittest.TestCase): + def runTest(self): util.invoke('upgrade') - def test_sync(self): +class TestSync(unittest.TestCase): + def runTest(self): util.invoke('sync') - def test_multi(self): +class TestMulti(unittest.TestCase): + def runTest(self): result = util.invoke( 'multi', { 'actions': [ From 9e24d82a39de5350edbd9243137472f877f3df34 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Sun, 6 May 2018 22:13:21 -0700 Subject: [PATCH 09/25] working on deck tests --- AnkiConnect.py | 10 +++++++++- tests/test_decks.py | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100755 tests/test_decks.py diff --git a/AnkiConnect.py b/AnkiConnect.py index 3ede0e5..f57c93b 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -360,6 +360,14 @@ class AnkiConnect: return collection + def decks(self): + decks = self.collection().decks + if decks is None: + raise Exception('decks are not available') + else: + return decks + + def scheduler(self): scheduler = self.collection().sched if scheduler is None: @@ -472,7 +480,7 @@ class AnkiConnect: @api() def deckNames(self): - return self.collection().decks.allNames() + return self.decks().allnames() @api() diff --git a/tests/test_decks.py b/tests/test_decks.py new file mode 100755 index 0000000..8bfc0da --- /dev/null +++ b/tests/test_decks.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python + +import unittest +import util + + +class TestDeckNames(unittest.TestCase): + def runTest(self): + result = util.invoke('deckNames') + self.assertIn('Default', result) + + +class TestDeckNamesAndIds(unittest.TestCase): + def runTest(self): + result = util.invoke('deckNamesAndIds') + self.assertIn('Default', result) + self.assertEqual(result['Default'], 1) + + +if __name__ == '__main__': + unittest.main() From 1e32f05bd532d97b6889a092cf0cda157d1ff254 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Sun, 6 May 2018 22:14:18 -0700 Subject: [PATCH 10/25] typo --- AnkiConnect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index f57c93b..3e160c4 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -480,7 +480,7 @@ class AnkiConnect: @api() def deckNames(self): - return self.decks().allnames() + return self.decks().allNames() @api() From 7a0b4e656dd0ee7e1409e80da9c44cd85bcd55fb Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Mon, 7 May 2018 11:02:51 -0700 Subject: [PATCH 11/25] cleanup --- AnkiConnect.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index 3e160c4..ba76a6a 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -462,16 +462,12 @@ class AnkiConnect: @api() def sync(self): - print self.window().onSync() + self.window().onSync() @api() def multi(self, actions): - response = [] - for item in actions: - response.append(self.handler(item)) - - return response + return map(self.handler, actions) # @@ -487,7 +483,7 @@ class AnkiConnect: def deckNamesAndIds(self): decks = {} for deck in self.deckNames(): - decks[deck] = self.collection().decks.id(deck) + decks[deck] = self.decks().id(deck) return decks From 409245c41bf5099e4c4f199ad9aa9ac5750d1708 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Mon, 7 May 2018 14:18:20 -0700 Subject: [PATCH 12/25] wip --- AnkiConnect.py | 61 +++++++++++++++++++++++++++------------------ tests/test_decks.py | 20 +++++++++++++++ 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index ba76a6a..d9d8a2d 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -376,6 +376,14 @@ class AnkiConnect: return scheduler + def database(self): + database = self.collection().db + if database is None: + raise Exception('database is not available') + else: + return database + + def media(self): media = self.collection().media if media is None: @@ -491,11 +499,9 @@ class AnkiConnect: @api() def getDecks(self, cards): decks = {} - collection = self.collection() for card in cards: - did = collection.db.scalar('select did from cards where id = ?', card) - deck = collection.decks.get(did)['name'] - + did = self.database().scalar('select did from cards where id=?', card) + deck = self.decks().get(did)['name'] if deck in decks: decks[deck].append(card) else: @@ -506,38 +512,45 @@ class AnkiConnect: @api() def createDeck(self, deck): - self.startEditing() - deckId = self.collection().decks.id(deck) - self.stopEditing() + try: + self.startEditing() + did = self.decks().id(deck) + finally: + self.stopEditing() - return deckId + return did @api() def changeDeck(self, cards, deck): - self.startEditing() + try: + self.startEditing() - did = self.collection().decks.id(deck) - mod = anki.utils.intTime() - usn = self.collection().usn() + did = self.collection().decks.id(deck) + mod = anki.utils.intTime() + usn = self.collection().usn() - # normal cards - scids = anki.utils.ids2str(cards) - # remove any cards from filtered deck first - self.collection().sched.remFromDyn(cards) + # normal cards + scids = anki.utils.ids2str(cards) + # remove any cards from filtered deck first + self.collection().sched.remFromDyn(cards) - # then move into new deck - self.collection().db.execute('update cards set usn=?, mod=?, did=? where id in ' + scids, usn, mod, did) - self.stopEditing() + # then move into new deck + self.collection().db.execute('update cards set usn=?, mod=?, did=? where id in ?', scids, usn, mod, did) + finally: + self.stopEditing() @api() def deleteDecks(self, decks, cardsToo=False): - self.startEditing() - for deck in decks: - did = self.collection().decks.id(deck) - self.collection().decks.rem(did, cardsToo) - self.stopEditing() + try: + self.startEditing() + decks = filter(lambda d: d in self.deckNames(), decks) + for deck in decks: + did = self.decks().id(deck) + self.decks().rem(did, cardsToo) + finally: + self.stopEditing() @api() diff --git a/tests/test_decks.py b/tests/test_decks.py index 8bfc0da..835bedc 100755 --- a/tests/test_decks.py +++ b/tests/test_decks.py @@ -17,5 +17,25 @@ class TestDeckNamesAndIds(unittest.TestCase): self.assertEqual(result['Default'], 1) +class TestCreateDeck(unittest.TestCase): + def tearDown(self): + util.invoke('deleteDecks', {'decks': ['test']}) + + + def runTest(self): + util.invoke('createDeck', {'deck': 'test'}) + self.assertIn('test', util.invoke('deckNames')) + + +class TestDeleteDecks(unittest.TestCase): + def setUp(self): + util.invoke('createDeck', {'deck': 'test'}) + + + def runTest(self): + util.invoke('deleteDecks', {'decks': ['test']}) + self.assertNotIn('test', util.invoke('deckNames')) + + if __name__ == '__main__': unittest.main() From e63f2116cda8ce15efd9f0281d2950d1b4591498 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Mon, 7 May 2018 14:34:41 -0700 Subject: [PATCH 13/25] cleanup --- tests/test_decks.py | 8 ++++---- tests/test_misc.py | 16 +++++++--------- tests/util.py | 15 +++++++++------ 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/tests/test_decks.py b/tests/test_decks.py index 835bedc..f71601d 100755 --- a/tests/test_decks.py +++ b/tests/test_decks.py @@ -19,21 +19,21 @@ class TestDeckNamesAndIds(unittest.TestCase): class TestCreateDeck(unittest.TestCase): def tearDown(self): - util.invoke('deleteDecks', {'decks': ['test']}) + util.invoke('deleteDecks', decks=['test']) def runTest(self): - util.invoke('createDeck', {'deck': 'test'}) + util.invoke('createDeck', deck='test') self.assertIn('test', util.invoke('deckNames')) class TestDeleteDecks(unittest.TestCase): def setUp(self): - util.invoke('createDeck', {'deck': 'test'}) + util.invoke('createDeck', deck='test') def runTest(self): - util.invoke('deleteDecks', {'decks': ['test']}) + util.invoke('deleteDecks', decks=['test']) self.assertNotIn('test', util.invoke('deckNames')) diff --git a/tests/test_misc.py b/tests/test_misc.py index 9de5d9e..ad1ea9e 100755 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -6,8 +6,7 @@ import util class TestVersion(unittest.TestCase): def runTest(self): - result = util.invoke('version') - self.assertEqual(result, 5) + self.assertEqual(util.invoke('version'), 5) class TestUpgrade(unittest.TestCase): @@ -23,13 +22,12 @@ class TestSync(unittest.TestCase): class TestMulti(unittest.TestCase): def runTest(self): result = util.invoke( - 'multi', { - 'actions': [ - util.request('version'), - util.request('version'), - util.request('version') - ] - } + 'multi', + actions=[ + util.request('version'), + util.request('version'), + util.request('version') + ] ) self.assertEqual(len(result), 3) diff --git a/tests/util.py b/tests/util.py index 2657867..3435b97 100644 --- a/tests/util.py +++ b/tests/util.py @@ -1,14 +1,17 @@ import json import urllib2 - -def request(action, params={}, version=5): - return {'action': action, 'params': params, 'version': version} +API_VERSION = 5 +API_URL = 'http://localhost:8765' -def invoke(action, params={}, version=5, url='http://localhost:8765'): - requestJson = json.dumps(request(action, params, version)) - response = json.load(urllib2.urlopen(urllib2.Request(url, requestJson))) +def request(action, **params): + return {'action': action, 'params': params, 'version': API_VERSION} + + +def invoke(action, **params): + requestJson = json.dumps(request(action, **params)) + response = json.load(urllib2.urlopen(urllib2.Request(API_URL, requestJson))) if len(response) != 2: raise Exception('response has an unexpected number of fields') if 'error' not in response: From ba42cedf1af5762fd54201db1a75380e3c56f623 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Tue, 8 May 2018 15:13:49 -0700 Subject: [PATCH 14/25] unit tests for notes --- AnkiConnect.py | 20 ++++----- tests/test_notes.py | 105 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 10 deletions(-) create mode 100755 tests/test_notes.py diff --git a/AnkiConnect.py b/AnkiConnect.py index d9d8a2d..4344d6f 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -647,8 +647,8 @@ class AnkiConnect: def addNote(self, note): ankiNote = self.createNote(note) - if note['audio'] is not None and len(note['audio']['fields']) > 0: - audio = note['audio'] + audio = note.get('audio') + if audio is not None and len(audio['fields']) > 0: data = download(audio['url']) if data is not None: if audio['skipHash'] is None: @@ -683,16 +683,16 @@ class AnkiConnect: @api() - def updateNoteFields(self, params): - note = self.collection().getNote(params['id']) - if note is None: - raise Exception('note was not found: {}'.format(params['id'])) + def updateNoteFields(self, note): + ankiNote = self.collection().getNote(note['id']) + if ankiNote is None: + raise Exception('note was not found: {}'.format(note['id'])) - for name, value in params['fields'].items(): - if name in note: - note[name] = value + for name, value in note['fields'].items(): + if name in ankiNote: + ankiNote[name] = value - note.flush() + ankiNote.flush() @api() diff --git a/tests/test_notes.py b/tests/test_notes.py new file mode 100755 index 0000000..ee70476 --- /dev/null +++ b/tests/test_notes.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python + +import unittest +import util + + +class TestNotes(unittest.TestCase): + def setUp(self): + util.invoke('createDeck', deck='test') + + + def tearDown(self): + util.invoke('deleteDecks', decks=['test'], cardsToo=True) + + + def runTest(self): + note = { + 'deckName': 'test', + 'modelName': 'Basic', + 'fields': {'Front': 'front1', 'Back': 'back1'}, + 'tags': ['tag1'] + } + + # addNote + noteId = util.invoke('addNote', note=note) + self.assertRaises(Exception, lambda: util.invoke('addNote', note=note)) + + # addTags + util.invoke('addTags', notes=[noteId], tags='tag2') + + # notesInfo (part 1) + noteInfos = util.invoke('notesInfo', notes=[noteId]) + self.assertEqual(len(noteInfos), 1) + noteInfo = noteInfos[0] + self.assertEqual(noteInfo['noteId'], noteId) + self.assertSetEqual(set(noteInfo['tags']), {'tag1', 'tag2'}) + self.assertEqual(noteInfo['fields']['Front']['value'], 'front1') + self.assertEqual(noteInfo['fields']['Back']['value'], 'back1') + + # getTags + allTags = util.invoke('getTags') + self.assertIn('tag1', allTags) + self.assertIn('tag2', allTags) + + # removeTags + util.invoke('removeTags', notes=[noteId], tags='tag2') + + # updateNoteFields + noteUpdate = {'id': noteId, 'fields': {'Front': 'front2', 'Back': 'back2'}} + util.invoke('updateNoteFields', note=noteUpdate) + + # notesInfo (part 2) + noteInfos = util.invoke('notesInfo', notes=[noteId]) + self.assertEqual(len(noteInfos), 1) + noteInfo = noteInfos[0] + self.assertSetEqual(set(noteInfo['tags']), {'tag1'}) + self.assertIn('tag1', noteInfo['tags']) + self.assertNotIn('tag2', noteInfo['tags']) + self.assertEqual(noteInfo['fields']['Front']['value'], 'front2') + self.assertEqual(noteInfo['fields']['Back']['value'], 'back2') + + notes = [ + { + 'deckName': 'test', + 'modelName': 'Basic', + 'fields': {'Front': 'front3', 'Back': 'back3'}, + 'tags': ['tag'] + }, + { + 'deckName': 'test', + 'modelName': 'Basic', + 'fields': {'Front': 'front4', 'Back': 'back4'}, + 'tags': ['tag'] + } + ] + + # canAddNotes (part 1) + noteStates = util.invoke('canAddNotes', notes=notes) + self.assertEqual(len(noteStates), len(notes)) + self.assertNotIn(False, noteStates) + + # addNotes (part 1) + noteIds = util.invoke('addNotes', notes=notes) + self.assertEqual(len(noteIds), len(notes)) + for noteId in noteIds: + self.assertNotEqual(noteId, None) + + # canAddNotes (part 2) + noteStates = util.invoke('canAddNotes', notes=notes) + self.assertNotIn(True, noteStates) + self.assertEqual(len(noteStates), len(notes)) + + # addNotes (part 2) + noteIds = util.invoke('addNotes', notes=notes) + self.assertEqual(len(noteIds), len(notes)) + for noteId in noteIds: + self.assertEqual(noteId, None) + + # findNotes + noteIds = util.invoke('findNotes', query='deck:test') + self.assertEqual(len(noteIds), len(notes) + 1) + + +if __name__ == '__main__': + unittest.main() From a7a03ab1b98d3fa75cb6c4b60a1c660c4994db34 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Tue, 8 May 2018 15:18:21 -0700 Subject: [PATCH 15/25] unit test cleanup for misc --- tests/test_misc.py | 34 +++++++++++----------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/tests/test_misc.py b/tests/test_misc.py index ad1ea9e..e1b29a8 100755 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -4,36 +4,24 @@ import unittest import util -class TestVersion(unittest.TestCase): +class TestMisc(unittest.TestCase): def runTest(self): + # version self.assertEqual(util.invoke('version'), 5) - -class TestUpgrade(unittest.TestCase): - def runTest(self): + # upgrade util.invoke('upgrade') - -class TestSync(unittest.TestCase): - def runTest(self): + # sync util.invoke('sync') - -class TestMulti(unittest.TestCase): - def runTest(self): - result = util.invoke( - 'multi', - actions=[ - util.request('version'), - util.request('version'), - util.request('version') - ] - ) - - self.assertEqual(len(result), 3) - for response in result: - self.assertIsNone(response['error']) - self.assertEqual(response['result'], 5) + # multi + actions = [util.request('version'), util.request('version'), util.request('version')] + results = util.invoke('multi', actions=actions) + self.assertEqual(len(results), len(actions)) + for result in results: + self.assertIsNone(result['error']) + self.assertEqual(result['result'], 5) if __name__ == '__main__': From 4c86400d3276b90eb2b92a244936ef57d7cfe3a1 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Tue, 8 May 2018 17:22:42 -0700 Subject: [PATCH 16/25] cleanup deck tests --- tests/test_decks.py | 35 ++++++++++++++--------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/tests/test_decks.py b/tests/test_decks.py index f71601d..58c3b9c 100755 --- a/tests/test_decks.py +++ b/tests/test_decks.py @@ -4,37 +4,30 @@ import unittest import util -class TestDeckNames(unittest.TestCase): +class TestDecks(unittest.TestCase): def runTest(self): - result = util.invoke('deckNames') - self.assertIn('Default', result) + # deckNames (part 1) + deckNames = util.invoke('deckNames') + self.assertIn('Default', deckNames) - -class TestDeckNamesAndIds(unittest.TestCase): - def runTest(self): + # deckNamesAndIds result = util.invoke('deckNamesAndIds') self.assertIn('Default', result) self.assertEqual(result['Default'], 1) - -class TestCreateDeck(unittest.TestCase): - def tearDown(self): - util.invoke('deleteDecks', decks=['test']) - - - def runTest(self): - util.invoke('createDeck', deck='test') - self.assertIn('test', util.invoke('deckNames')) - - -class TestDeleteDecks(unittest.TestCase): - def setUp(self): + # createDeck util.invoke('createDeck', deck='test') + # deckNames (part 2) + deckNames = util.invoke('deckNames') + self.assertIn('test', deckNames) - def runTest(self): + # deleteDecks util.invoke('deleteDecks', decks=['test']) - self.assertNotIn('test', util.invoke('deckNames')) + + # deckNames (part 3) + deckNames = util.invoke('deckNames') + self.assertNotIn('test', deckNames) if __name__ == '__main__': From 9eabc14b7c07369678fda35d9037a032e2be0bfe Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Tue, 8 May 2018 17:41:27 -0700 Subject: [PATCH 17/25] add model tests --- tests/test_models.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100755 tests/test_models.py diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100755 index 0000000..7f3c07c --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python + +import unittest +import util + + +class TestModels(unittest.TestCase): + def runTest(self): + # modelNames + modelNames = util.invoke('modelNames') + self.assertGreater(len(modelNames), 0) + + # modelNamesAndIds + modelNamesAndIds = util.invoke('modelNamesAndIds') + self.assertGreater(len(modelNames), 0) + + # modelFieldNames + modelFields = util.invoke('modelFieldNames', modelName=modelNames[0]) + + # modelFieldsOnTemplates + modelFieldsOnTemplates = util.invoke('modelFieldsOnTemplates', modelName=modelNames[0]) + + +if __name__ == '__main__': + unittest.main() From d3258e2da4ae8e7ce71ff39c07c2531a7612c01a Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Tue, 8 May 2018 17:55:45 -0700 Subject: [PATCH 18/25] add media tests --- tests/test_media.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100755 tests/test_media.py diff --git a/tests/test_media.py b/tests/test_media.py new file mode 100755 index 0000000..7870fbc --- /dev/null +++ b/tests/test_media.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python + +import unittest +import util + + +class TestMedia(unittest.TestCase): + def runTest(self): + filename = '_test.txt' + data = 'test' + + # storeMediaFile + util.invoke('storeMediaFile', filename='_test.txt', data=data) + + # retrieveMediaFile (part 1) + media = util.invoke('retrieveMediaFile', filename=filename) + self.assertEqual(media, data) + + # deleteMediaFile + util.invoke('deleteMediaFile', filename=filename) + + # retrieveMediaFile (part 2) + media = util.invoke('retrieveMediaFile', filename=filename) + self.assertFalse(media) + + +if __name__ == '__main__': + unittest.main() From c39e092a04add6c993569ab50b059cddf732b2be Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Tue, 8 May 2018 18:16:36 -0700 Subject: [PATCH 19/25] add graphical tests --- tests/test_graphical.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100755 tests/test_graphical.py diff --git a/tests/test_graphical.py b/tests/test_graphical.py new file mode 100755 index 0000000..5eb9dd6 --- /dev/null +++ b/tests/test_graphical.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python + +import unittest +import util + + +class TestGui(unittest.TestCase): + def runTest(self): + # guiBrowse + util.invoke('guiBrowse', query='deck:Default') + + # guiAddCards + util.invoke('guiAddCards') + + # guiCurrentCard + # util.invoke('guiCurrentCard') + + # guiStartCardTimer + util.invoke('guiStartCardTimer') + + # guiShowQuestion + util.invoke('guiShowQuestion') + + # guiShowAnswer + util.invoke('guiShowAnswer') + + # guiAnswerCard + util.invoke('guiAnswerCard', ease=1) + + # guiDeckOverview + util.invoke('guiDeckOverview', name='Default') + + # guiDeckBrowser + util.invoke('guiDeckBrowser') + + +if __name__ == '__main__': + unittest.main() From 4ebcffb698e648389a52bed3d3a2ffe2431286ca Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Tue, 8 May 2018 18:17:26 -0700 Subject: [PATCH 20/25] work on graphical tests --- tests/test_graphical.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_graphical.py b/tests/test_graphical.py index 5eb9dd6..97a68c8 100755 --- a/tests/test_graphical.py +++ b/tests/test_graphical.py @@ -33,6 +33,9 @@ class TestGui(unittest.TestCase): # guiDeckBrowser util.invoke('guiDeckBrowser') + # guiExitAnki + # util.invoke('guiExitAnki') + if __name__ == '__main__': unittest.main() From 20a39f7604abd2983b3832ae6592b34fc29033b7 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Tue, 8 May 2018 18:29:29 -0700 Subject: [PATCH 21/25] work on deck tests --- tests/test_decks.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_decks.py b/tests/test_decks.py index 58c3b9c..e3fb86f 100755 --- a/tests/test_decks.py +++ b/tests/test_decks.py @@ -29,6 +29,32 @@ class TestDecks(unittest.TestCase): deckNames = util.invoke('deckNames') self.assertNotIn('test', deckNames) + # getDeckConfig + deckConfig = util.invoke('getDeckConfig', deck='Default') + self.assertEqual('Default', deckConfig['name']) + + # saveDeckConfig + deckConfig = util.invoke('saveDeckConfig', config=deckConfig) + + # setDeckConfigId + setDeckConfigId = util.invoke('setDeckConfigId', decks=['Default'], configId=1) + self.assertTrue(setDeckConfigId) + + # cloneDeckConfigId (part 1) + deckConfigId = util.invoke('cloneDeckConfigId', cloneFrom=1, name='test') + self.assertTrue(deckConfigId) + + # removeDeckConfigId (part 1) + removedDeckConfigId = util.invoke('removeDeckConfigId', configId=deckConfigId) + self.assertTrue(removedDeckConfigId) + + # removeDeckConfigId (part 2) + removedDeckConfigId = util.invoke('removeDeckConfigId', configId=deckConfigId) + self.assertFalse(removedDeckConfigId) + + # cloneDeckConfigId (part 2) + deckConfigId = util.invoke('cloneDeckConfigId', cloneFrom=deckConfigId, name='test') + self.assertFalse(deckConfigId) if __name__ == '__main__': unittest.main() From f79c98bb96f475335e5237af5b74fe85d9519734 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Tue, 8 May 2018 18:31:52 -0700 Subject: [PATCH 22/25] rename zip script --- build_zip.sh => zip.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename build_zip.sh => zip.sh (100%) diff --git a/build_zip.sh b/zip.sh similarity index 100% rename from build_zip.sh rename to zip.sh From 95ab8164cd68bd1171826e13af930d4fc3ee5c12 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Tue, 8 May 2018 18:47:45 -0700 Subject: [PATCH 23/25] updating note and deck tests --- AnkiConnect.py | 24 +++++++++++------------- tests/test_decks.py | 18 +++++++++++++++++- tests/test_notes.py | 22 +++------------------- 3 files changed, 31 insertions(+), 33 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index 4344d6f..5a2a4b4 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -523,22 +523,20 @@ class AnkiConnect: @api() def changeDeck(self, cards, deck): - try: - self.startEditing() + self.startEditing() - did = self.collection().decks.id(deck) - mod = anki.utils.intTime() - usn = self.collection().usn() + did = self.collection().decks.id(deck) + mod = anki.utils.intTime() + usn = self.collection().usn() - # normal cards - scids = anki.utils.ids2str(cards) - # remove any cards from filtered deck first - self.collection().sched.remFromDyn(cards) + # normal cards + scids = anki.utils.ids2str(cards) + # remove any cards from filtered deck first + self.collection().sched.remFromDyn(cards) - # then move into new deck - self.collection().db.execute('update cards set usn=?, mod=?, did=? where id in ?', scids, usn, mod, did) - finally: - self.stopEditing() + # then move into new deck + self.collection().db.execute('update cards set usn=?, mod=?, did=? where id in ' + scids, usn, mod, did) + self.stopEditing() @api() diff --git a/tests/test_decks.py b/tests/test_decks.py index e3fb86f..1619a0b 100755 --- a/tests/test_decks.py +++ b/tests/test_decks.py @@ -23,12 +23,28 @@ class TestDecks(unittest.TestCase): self.assertIn('test', deckNames) # deleteDecks - util.invoke('deleteDecks', decks=['test']) + util.invoke('deleteDecks', decks=['test'], cardsToo=True) # deckNames (part 3) deckNames = util.invoke('deckNames') self.assertNotIn('test', deckNames) + # changeDeck + # note = {'deckName': 'Default', 'modelName': 'Basic', 'fields': {'Front': 'front', 'Back': 'back'}, 'tags': ['tag']} + # noteId = util.invoke('addNote', note=note) + # util.invoke('changeDeck', cards=[noteId], deck='test') + + # deckNames (part 4) + # deckNames = util.invoke('deckNames') + # self.assertIn('test', deckNames) + + # deleteDecks (part 2) + # util.invoke('deleteDecks', decks=['test'], cardsToo=True) + + # deckNames (part 5) + # deckNames = util.invoke('deckNames') + # self.assertNotIn('test', deckNames) + # getDeckConfig deckConfig = util.invoke('getDeckConfig', deck='Default') self.assertEqual('Default', deckConfig['name']) diff --git a/tests/test_notes.py b/tests/test_notes.py index ee70476..eddac05 100755 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -14,14 +14,8 @@ class TestNotes(unittest.TestCase): def runTest(self): - note = { - 'deckName': 'test', - 'modelName': 'Basic', - 'fields': {'Front': 'front1', 'Back': 'back1'}, - 'tags': ['tag1'] - } - # addNote + note = {'deckName': 'test', 'modelName': 'Basic', 'fields': {'Front': 'front1', 'Back': 'back1'}, 'tags': ['tag1']} noteId = util.invoke('addNote', note=note) self.assertRaises(Exception, lambda: util.invoke('addNote', note=note)) @@ -60,18 +54,8 @@ class TestNotes(unittest.TestCase): self.assertEqual(noteInfo['fields']['Back']['value'], 'back2') notes = [ - { - 'deckName': 'test', - 'modelName': 'Basic', - 'fields': {'Front': 'front3', 'Back': 'back3'}, - 'tags': ['tag'] - }, - { - 'deckName': 'test', - 'modelName': 'Basic', - 'fields': {'Front': 'front4', 'Back': 'back4'}, - 'tags': ['tag'] - } + {'deckName': 'test', 'modelName': 'Basic', 'fields': {'Front': 'front3', 'Back': 'back3'}, 'tags': ['tag']}, + {'deckName': 'test', 'modelName': 'Basic', 'fields': {'Front': 'front4', 'Back': 'back4'}, 'tags': ['tag']} ] # canAddNotes (part 1) From 968341a66f63fa7a4325b2db57c4282eebd907d3 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Tue, 8 May 2018 20:38:28 -0700 Subject: [PATCH 24/25] add unit tests for cards --- tests/test_cards.py | 61 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100755 tests/test_cards.py diff --git a/tests/test_cards.py b/tests/test_cards.py new file mode 100755 index 0000000..372002b --- /dev/null +++ b/tests/test_cards.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python + +import unittest +import util + + +class TestCards(unittest.TestCase): + def setUp(self): + util.invoke('createDeck', deck='test') + note = {'deckName': 'test', 'modelName': 'Basic', 'fields': {'Front': 'front1', 'Back': 'back1'}, 'tags': ['tag1']} + self.noteId = util.invoke('addNote', note=note) + + + def tearDown(self): + util.invoke('deleteDecks', decks=['test'], cardsToo=True) + + + def runTest(self): + # findCards + cardIds = util.invoke('findCards', query='deck:test') + self.assertEqual(len(cardIds), 1) + + # suspend + util.invoke('suspend', cards=cardIds) + + # areSuspended (part 1) + suspendedStates = util.invoke('areSuspended', cards=cardIds) + self.assertEqual(len(cardIds), len(suspendedStates)) + self.assertNotIn(False, suspendedStates) + + # unsuspend + util.invoke('unsuspend', cards=cardIds) + + # areSuspended (part 2) + suspendedStates = util.invoke('areSuspended', cards=cardIds) + self.assertEqual(len(cardIds), len(suspendedStates)) + self.assertNotIn(True, suspendedStates) + + # areDue + dueStates = util.invoke('areDue', cards=cardIds) + self.assertEqual(len(cardIds), len(dueStates)) + self.assertNotIn(False, dueStates) + + # getIntervals + util.invoke('getIntervals', cards=cardIds, complete=True) + util.invoke('getIntervals', cards=cardIds, complete=False) + + # cardsToNotes + noteIds = util.invoke('cardsToNotes', cards=cardIds) + self.assertEqual(len(noteIds), len(cardIds)) + self.assertIn(self.noteId, noteIds) + + # cardsInfo + cardsInfo = util.invoke('cardsInfo', cards=cardIds) + self.assertEqual(len(cardsInfo), len(cardIds)) + for i, cardInfo in enumerate(cardsInfo): + self.assertEqual(cardInfo['cardId'], cardIds[i]) + + +if __name__ == '__main__': + unittest.main() From cd11abfc2a72a304db13dd69718917464b041b87 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Wed, 9 May 2018 14:20:20 -0700 Subject: [PATCH 25/25] fix changeDeck tests --- tests/test_decks.py | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/tests/test_decks.py b/tests/test_decks.py index 1619a0b..7edd95a 100755 --- a/tests/test_decks.py +++ b/tests/test_decks.py @@ -16,34 +16,29 @@ class TestDecks(unittest.TestCase): self.assertEqual(result['Default'], 1) # createDeck - util.invoke('createDeck', deck='test') + util.invoke('createDeck', deck='test1') # deckNames (part 2) deckNames = util.invoke('deckNames') - self.assertIn('test', deckNames) + self.assertIn('test1', deckNames) - # deleteDecks - util.invoke('deleteDecks', decks=['test'], cardsToo=True) + # changeDeck + note = {'deckName': 'test1', 'modelName': 'Basic', 'fields': {'Front': 'front', 'Back': 'back'}, 'tags': ['tag']} + noteId = util.invoke('addNote', note=note) + cardIds = util.invoke('findCards', query='deck:test1') + util.invoke('changeDeck', cards=cardIds, deck='test2') # deckNames (part 3) deckNames = util.invoke('deckNames') - self.assertNotIn('test', deckNames) + self.assertIn('test2', deckNames) - # changeDeck - # note = {'deckName': 'Default', 'modelName': 'Basic', 'fields': {'Front': 'front', 'Back': 'back'}, 'tags': ['tag']} - # noteId = util.invoke('addNote', note=note) - # util.invoke('changeDeck', cards=[noteId], deck='test') + # deleteDecks + util.invoke('deleteDecks', decks=['test1', 'test2'], cardsToo=True) # deckNames (part 4) - # deckNames = util.invoke('deckNames') - # self.assertIn('test', deckNames) - - # deleteDecks (part 2) - # util.invoke('deleteDecks', decks=['test'], cardsToo=True) - - # deckNames (part 5) - # deckNames = util.invoke('deckNames') - # self.assertNotIn('test', deckNames) + deckNames = util.invoke('deckNames') + self.assertNotIn('test1', deckNames) + self.assertNotIn('test2', deckNames) # getDeckConfig deckConfig = util.invoke('getDeckConfig', deck='Default')