anki-connect/AnkiConnect.py

1407 lines
40 KiB
Python
Raw Normal View History

2016-05-21 22:10:12 +00:00
# Copyright (C) 2016 Alex Yatskov <alex@foosoft.net>
# Author: Alex Yatskov <alex@foosoft.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import anki
import aqt
2017-08-22 07:53:35 +00:00
import base64
2016-07-17 05:01:23 +00:00
import hashlib
import inspect
2016-05-21 22:10:12 +00:00
import json
import os
2017-01-30 01:34:18 +00:00
import os.path
2017-08-21 14:10:57 +00:00
import re
2016-05-21 22:10:12 +00:00
import select
import socket
2017-07-06 04:29:46 +00:00
import sys
from operator import itemgetter
from time import time
2017-08-22 19:05:12 +00:00
from unicodedata import normalize
2019-01-24 07:36:11 +00:00
from random import choice
2019-01-24 06:07:53 +00:00
from string import ascii_letters
2016-05-21 22:10:12 +00:00
2016-05-29 22:49:38 +00:00
#
# Constants
#
API_VERSION = 6
2018-06-30 18:23:13 +00:00
API_LOG_PATH = None
2018-05-07 00:52:24 +00:00
NET_ADDRESS = os.getenv('ANKICONNECT_BIND_ADDRESS', '127.0.0.1')
NET_BACKLOG = 5
NET_PORT = 8765
2017-02-19 20:07:10 +00:00
TICK_INTERVAL = 25
2018-05-07 00:52:24 +00:00
URL_TIMEOUT = 10
URL_UPGRADE = 'https://raw.githubusercontent.com/FooSoft/anki-connect/master/AnkiConnect.py'
2016-05-29 22:49:38 +00:00
2019-01-24 07:36:11 +00:00
ANKI21 = anki.version.startswith('2.1')
2016-05-29 22:49:38 +00:00
2017-01-29 01:31:33 +00:00
#
2018-05-07 00:52:24 +00:00
# Helpers
2017-01-29 01:31:33 +00:00
#
2017-07-06 04:29:46 +00:00
if sys.version_info[0] < 3:
2017-01-29 01:31:33 +00:00
import urllib2
def download(url):
contents = None
resp = urllib2.urlopen(url, timeout=URL_TIMEOUT)
if resp.code == 200:
contents = resp.read()
return (resp.code, contents)
2017-01-29 01:31:33 +00:00
2017-02-19 20:46:40 +00:00
from PyQt4.QtCore import QTimer
from PyQt4.QtGui import QMessageBox
2017-07-06 04:29:46 +00:00
else:
unicode = str
from anki.sync import AnkiRequestsClient
def download(url):
contents = None
client = AnkiRequestsClient()
client.timeout = URL_TIMEOUT
resp = client.get(url)
if resp.status_code == 200:
contents = client.streamContent(resp)
return (resp.status_code, contents)
2017-07-06 04:29:46 +00:00
2017-02-19 20:46:40 +00:00
from PyQt5.QtCore import QTimer
from PyQt5.QtWidgets import QMessageBox
2017-01-29 01:31:33 +00:00
2017-02-19 20:46:40 +00:00
def makeBytes(data):
return data.encode('utf-8')
def makeStr(data):
return data.decode('utf-8')
2018-05-07 00:52:24 +00:00
def api(*versions):
def decorator(func):
method = lambda *args, **kwargs: func(*args, **kwargs)
setattr(method, 'versions', versions)
setattr(method, 'api', True)
return method
2017-02-19 20:46:40 +00:00
2018-05-07 00:52:24 +00:00
return decorator
2017-02-19 20:46:40 +00:00
2017-01-29 01:31:33 +00:00
2016-05-21 22:10:12 +00:00
#
# WebRequest
2016-05-21 22:10:12 +00:00
#
class WebRequest:
2016-05-21 22:10:12 +00:00
def __init__(self, headers, body):
self.headers = headers
self.body = body
#
# WebClient
2016-05-21 22:10:12 +00:00
#
class WebClient:
2016-05-21 22:10:12 +00:00
def __init__(self, sock, handler):
self.sock = sock
self.handler = handler
2017-01-29 01:31:33 +00:00
self.readBuff = bytes()
self.writeBuff = bytes()
2016-05-21 22:10:12 +00:00
def advance(self, recvSize=1024):
if self.sock is None:
return False
rlist, wlist = select.select([self.sock], [self.sock], [], 0)[:2]
if rlist:
msg = self.sock.recv(recvSize)
if not msg:
self.close()
return False
self.readBuff += msg
req, length = self.parseRequest(self.readBuff)
if req is not None:
self.readBuff = self.readBuff[length:]
self.writeBuff += self.handler(req)
if wlist and self.writeBuff:
length = self.sock.send(self.writeBuff)
self.writeBuff = self.writeBuff[length:]
if not self.writeBuff:
self.close()
return False
return True
def close(self):
if self.sock is not None:
self.sock.close()
self.sock = None
2017-01-29 01:31:33 +00:00
self.readBuff = bytes()
self.writeBuff = bytes()
2016-05-21 22:10:12 +00:00
def parseRequest(self, data):
2017-01-29 01:31:33 +00:00
parts = data.split(makeBytes('\r\n\r\n'), 1)
2016-05-21 22:10:12 +00:00
if len(parts) == 1:
return None, 0
headers = {}
2017-01-29 01:31:33 +00:00
for line in parts[0].split(makeBytes('\r\n')):
pair = line.split(makeBytes(': '))
headers[pair[0].lower()] = pair[1] if len(pair) > 1 else None
2016-05-21 22:10:12 +00:00
headerLength = len(parts[0]) + 4
bodyLength = int(headers.get(makeBytes('content-length'), 0))
2016-05-21 22:10:12 +00:00
totalLength = headerLength + bodyLength
if totalLength > len(data):
return None, 0
body = data[headerLength : totalLength]
return WebRequest(headers, body), totalLength
2016-05-21 22:10:12 +00:00
#
# WebServer
2016-05-21 22:10:12 +00:00
#
class WebServer:
2016-05-21 22:10:12 +00:00
def __init__(self, handler):
self.handler = handler
self.clients = []
self.sock = None
2017-08-19 22:24:53 +00:00
self.resetHeaders()
2017-08-20 19:32:16 +00:00
def setHeader(self, name, value):
2018-05-07 00:52:24 +00:00
self.headersOpt[name] = value
2017-08-20 19:32:16 +00:00
2017-08-19 22:24:53 +00:00
def resetHeaders(self):
self.headers = [
['HTTP/1.1 200 OK', None],
2017-12-22 22:53:41 +00:00
['Content-Type', 'text/json'],
['Access-Control-Allow-Origin', '*']
]
2018-05-07 00:52:24 +00:00
self.headersOpt = {}
2017-08-20 19:32:16 +00:00
def getHeaders(self):
2017-08-20 21:39:50 +00:00
headers = self.headers[:]
2018-05-07 00:52:24 +00:00
for name in self.headersOpt:
headers.append([name, self.headersOpt[name]])
2017-08-20 19:32:16 +00:00
return headers
2016-05-21 22:10:12 +00:00
def advance(self):
if self.sock is not None:
self.acceptClients()
self.advanceClients()
def acceptClients(self):
rlist = select.select([self.sock], [], [], 0)[0]
if not rlist:
return
clientSock = self.sock.accept()[0]
if clientSock is not None:
clientSock.setblocking(False)
self.clients.append(WebClient(clientSock, self.handlerWrapper))
2016-05-21 22:10:12 +00:00
def advanceClients(self):
2017-01-29 01:31:33 +00:00
self.clients = list(filter(lambda c: c.advance(), self.clients))
2016-05-21 22:10:12 +00:00
2017-02-19 20:07:10 +00:00
def listen(self):
2016-05-21 22:10:12 +00:00
self.close()
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.setblocking(False)
2017-02-19 20:07:10 +00:00
self.sock.bind((NET_ADDRESS, NET_PORT))
self.sock.listen(NET_BACKLOG)
2016-05-21 22:10:12 +00:00
def handlerWrapper(self, req):
if len(req.body) == 0:
2017-02-19 21:18:07 +00:00
body = makeBytes('AnkiConnect v.{}'.format(API_VERSION))
else:
try:
params = json.loads(makeStr(req.body))
body = makeBytes(json.dumps(self.handler(params)))
except ValueError:
body = makeBytes(json.dumps(None))
2016-05-21 22:10:12 +00:00
resp = bytes()
2017-08-20 19:32:16 +00:00
self.setHeader('Content-Length', str(len(body)))
headers = self.getHeaders()
2016-05-21 22:10:12 +00:00
for key, value in headers:
2016-05-21 22:10:12 +00:00
if value is None:
2017-01-29 01:31:33 +00:00
resp += makeBytes('{}\r\n'.format(key))
2016-05-21 22:10:12 +00:00
else:
2017-01-29 01:31:33 +00:00
resp += makeBytes('{}: {}\r\n'.format(key, value))
2016-05-21 22:10:12 +00:00
2017-01-29 01:31:33 +00:00
resp += makeBytes('\r\n')
2016-05-21 22:10:12 +00:00
resp += body
return resp
def close(self):
if self.sock is not None:
self.sock.close()
self.sock = None
for client in self.clients:
client.close()
self.clients = []
2017-02-19 20:07:10 +00:00
#
2018-05-07 00:52:24 +00:00
# AnkiConnect
2016-05-21 22:10:12 +00:00
#
class AnkiConnect:
def __init__(self):
self.server = WebServer(self.handler)
2018-06-30 18:23:13 +00:00
self.log = None
if API_LOG_PATH is not None:
self.log = open(API_LOG_PATH, 'w')
try:
self.server.listen()
self.timer = QTimer()
self.timer.timeout.connect(self.advance)
self.timer.start(TICK_INTERVAL)
except:
QMessageBox.critical(
self.window(),
'AnkiConnect',
'Failed to listen on port {}.\nMake sure it is available and is not in use.'.format(NET_PORT)
)
def advance(self):
self.server.advance()
def handler(self, request):
2018-06-30 18:23:13 +00:00
if self.log is not None:
self.log.write('[request]\n')
json.dump(request, self.log, indent=4, sort_keys=True)
self.log.write('\n\n')
name = request.get('action', '')
version = request.get('version', 4)
params = request.get('params', {})
reply = {'result': None, 'error': None}
try:
method = None
for methodName, methodInst in inspect.getmembers(self, predicate=inspect.ismethod):
apiVersionLast = 0
apiNameLast = None
if getattr(methodInst, 'api', False):
for apiVersion, apiName in getattr(methodInst, 'versions', []):
if apiVersionLast < apiVersion <= version:
apiVersionLast = apiVersion
apiNameLast = apiName
if apiNameLast is None and apiVersionLast == 0:
apiNameLast = methodName
if apiNameLast is not None and apiNameLast == name:
method = methodInst
break
if method is None:
raise Exception('unsupported action')
else:
reply['result'] = methodInst(**params)
except Exception as e:
reply['error'] = str(e)
2018-06-30 18:23:13 +00:00
if version <= 4:
reply = reply['result']
if self.log is not None:
self.log.write('[reply]\n')
json.dump(reply, self.log, indent=4, sort_keys=True)
self.log.write('\n\n')
return reply
2018-05-07 00:52:24 +00:00
def download(self, url):
try:
(code, contents) = download(url)
except Exception as e:
raise Exception('{} download failed with error {}'.format(url, str(e)))
if code == 200:
return contents
2018-05-07 00:52:24 +00:00
else:
raise Exception('{} download failed with return code {}'.format(url, code))
def window(self):
return aqt.mw
def reviewer(self):
2018-05-07 00:52:24 +00:00
reviewer = self.window().reviewer
if reviewer is None:
raise Exception('reviewer is not available')
else:
return reviewer
def collection(self):
2018-05-07 00:52:24 +00:00
collection = self.window().col
if collection is None:
raise Exception('collection is not available')
else:
return collection
2018-05-07 05:13:21 +00:00
def decks(self):
decks = self.collection().decks
if decks is None:
raise Exception('decks are not available')
else:
return decks
def scheduler(self):
2018-05-07 00:52:24 +00:00
scheduler = self.collection().sched
if scheduler is None:
raise Exception('scheduler is not available')
else:
return scheduler
2018-05-07 21:18:20 +00:00
def database(self):
database = self.collection().db
if database is None:
raise Exception('database is not available')
else:
return database
def media(self):
2018-05-07 00:52:24 +00:00
media = self.collection().media
if media is None:
raise Exception('media is not available')
else:
return media
2018-05-07 00:52:24 +00:00
def startEditing(self):
self.window().requireReset()
def stopEditing(self):
if self.collection() is not None:
self.window().maybeReset()
def createNote(self, note):
collection = self.collection()
2018-05-07 00:52:24 +00:00
model = collection.models.byName(note['modelName'])
if model is None:
2018-05-07 00:52:24 +00:00
raise Exception('model was not found: {}'.format(note['modelName']))
2018-05-07 00:52:24 +00:00
deck = collection.decks.byName(note['deckName'])
if deck is None:
2018-05-07 00:52:24 +00:00
raise Exception('deck was not found: {}'.format(note['deckName']))
2018-05-07 00:52:24 +00:00
ankiNote = anki.notes.Note(collection, model)
ankiNote.model()['did'] = deck['id']
ankiNote.tags = note['tags']
2018-05-07 00:52:24 +00:00
for name, value in note['fields'].items():
if name in ankiNote:
ankiNote[name] = value
allowDuplicate = False
if 'options' in note:
if 'allowDuplicate' in note['options']:
allowDuplicate = note['options']['allowDuplicate']
if type(allowDuplicate) is not bool:
raise Exception('option parameter \'allowDuplicate\' must be boolean')
2018-05-07 00:52:24 +00:00
duplicateOrEmpty = ankiNote.dupeOrEmpty()
if duplicateOrEmpty == 1:
2018-05-07 00:52:24 +00:00
raise Exception('cannot create note because it is empty')
elif duplicateOrEmpty == 2:
if not allowDuplicate:
2018-06-20 16:52:46 +00:00
raise Exception('cannot create note because it is a duplicate')
else:
return ankiNote
elif duplicateOrEmpty == False:
2018-05-07 00:52:24 +00:00
return ankiNote
else:
raise Exception('cannot create note for unknown reason')
2018-05-07 02:11:54 +00:00
2018-05-07 01:45:56 +00:00
#
# 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:
2018-05-07 02:04:53 +00:00
data = self.download(URL_UPGRADE)
2018-05-07 01:45:56 +00:00
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
2018-05-07 02:04:53 +00:00
except Exception as e:
2018-05-07 01:45:56 +00:00
QMessageBox.critical(self.window(), 'AnkiConnect', 'Failed to download latest version.')
2018-05-07 02:04:53 +00:00
raise e
2018-05-07 01:45:56 +00:00
return False
@api()
def loadProfile(self, name):
2018-12-02 03:42:56 +00:00
if name not in self.window().pm.profiles():
return False
if not self.window().isVisible():
self.window().pm.load(name)
self.window().loadProfile()
self.window().profileDiag.closeWithoutQuitting()
else:
cur_profile = self.window().pm.name
if cur_profile != name:
self.window().unloadProfileAndShowProfileManager()
self.loadProfile(name)
2018-12-02 03:42:56 +00:00
return True
2019-03-07 18:19:35 +00:00
2018-05-07 01:45:56 +00:00
@api()
def sync(self):
2018-05-07 18:02:51 +00:00
self.window().onSync()
2018-05-07 01:45:56 +00:00
@api()
def multi(self, actions):
return list(map(self.handler, actions))
2018-05-07 01:45:56 +00:00
2018-05-07 02:11:54 +00:00
#
# Decks
#
@api()
def deckNames(self):
2018-05-07 05:14:18 +00:00
return self.decks().allNames()
2018-05-07 02:11:54 +00:00
@api()
def deckNamesAndIds(self):
decks = {}
for deck in self.deckNames():
2018-05-07 18:02:51 +00:00
decks[deck] = self.decks().id(deck)
2018-05-07 02:11:54 +00:00
return decks
@api()
def getDecks(self, cards):
decks = {}
for card in cards:
2018-05-07 21:18:20 +00:00
did = self.database().scalar('select did from cards where id=?', card)
deck = self.decks().get(did)['name']
2018-05-07 02:11:54 +00:00
if deck in decks:
decks[deck].append(card)
else:
decks[deck] = [card]
return decks
@api()
def createDeck(self, deck):
2018-05-07 21:18:20 +00:00
try:
self.startEditing()
did = self.decks().id(deck)
finally:
self.stopEditing()
2018-05-07 02:11:54 +00:00
2018-05-07 21:18:20 +00:00
return did
2018-05-07 02:11:54 +00:00
@api()
def changeDeck(self, cards, deck):
2018-05-09 01:47:45 +00:00
self.startEditing()
2018-05-07 02:11:54 +00:00
2018-05-09 01:47:45 +00:00
did = self.collection().decks.id(deck)
mod = anki.utils.intTime()
usn = self.collection().usn()
2018-05-07 02:11:54 +00:00
2018-05-09 01:47:45 +00:00
# normal cards
scids = anki.utils.ids2str(cards)
# remove any cards from filtered deck first
self.collection().sched.remFromDyn(cards)
2018-05-07 02:11:54 +00:00
2018-05-09 01:47:45 +00:00
# then move into new deck
self.collection().db.execute('update cards set usn=?, mod=?, did=? where id in ' + scids, usn, mod, did)
self.stopEditing()
2018-05-07 02:11:54 +00:00
@api()
def deleteDecks(self, decks, cardsToo=False):
2018-05-07 21:18:20 +00:00
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()
2018-05-07 02:11:54 +00:00
@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
2018-05-07 00:52:24 +00:00
@api()
2017-08-22 19:05:12 +00:00
def storeMediaFile(self, filename, data):
self.deleteMediaFile(filename)
self.media().writeData(filename, base64.b64decode(data))
2017-08-22 07:53:35 +00:00
2018-05-07 00:52:24 +00:00
@api()
2017-08-22 19:05:12 +00:00
def retrieveMediaFile(self, filename):
filename = os.path.basename(filename)
2018-03-11 22:10:07 +00:00
filename = normalize('NFC', filename)
2017-08-22 19:05:12 +00:00
filename = self.media().stripIllegal(filename)
2017-08-22 07:53:35 +00:00
2017-08-22 19:05:12 +00:00
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')
2017-08-22 07:53:35 +00:00
return False
2018-05-07 00:52:24 +00:00
@api()
2017-08-22 19:05:12 +00:00
def deleteMediaFile(self, filename):
self.media().syncDelete(filename)
2017-08-22 07:53:35 +00:00
2018-05-07 00:52:24 +00:00
@api()
def addNote(self, note):
2018-05-07 00:52:24 +00:00
ankiNote = self.createNote(note)
2016-05-21 22:34:09 +00:00
2018-05-08 22:13:49 +00:00
audio = note.get('audio')
if audio is not None and len(audio['fields']) > 0:
try:
2018-06-23 01:51:22 +00:00
data = self.download(audio['url'])
2018-06-23 16:53:28 +00:00
skipHash = audio.get('skipHash')
if skipHash is None:
2017-01-30 01:34:18 +00:00
skip = False
else:
m = hashlib.md5()
m.update(data)
2018-06-23 16:53:28 +00:00
skip = skipHash == m.hexdigest()
2017-01-30 01:34:18 +00:00
if not skip:
audioFilename = self.media().writeData(audio['filename'], data)
2018-05-07 00:52:24 +00:00
for field in audio['fields']:
if field in ankiNote:
ankiNote[field] += u'[sound:{}]'.format(audioFilename)
2019-03-07 18:19:35 +00:00
except Exception as e:
errorMessage = str(e).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
for field in audio['fields']:
if field in ankiNote:
ankiNote[field] += errorMessage
2018-05-07 00:52:24 +00:00
collection = self.collection()
2016-07-17 16:38:33 +00:00
self.startEditing()
nCardsAdded = collection.addNote(ankiNote)
if nCardsAdded < 1:
raise Exception('The field values you have provided would make an empty question on all cards.')
2016-05-21 22:34:09 +00:00
collection.autosave()
2016-07-17 16:38:33 +00:00
self.stopEditing()
2016-05-21 22:34:09 +00:00
2018-05-07 00:52:24 +00:00
return ankiNote.id
2016-05-21 22:10:12 +00:00
2018-05-07 00:52:24 +00:00
@api()
2017-02-19 20:07:10 +00:00
def canAddNote(self, note):
try:
2018-05-07 00:52:24 +00:00
return bool(self.createNote(note))
except:
return False
2016-05-21 22:10:12 +00:00
2018-05-07 00:52:24 +00:00
@api()
2018-05-08 22:13:49 +00:00
def updateNoteFields(self, note):
ankiNote = self.collection().getNote(note['id'])
if ankiNote is None:
raise Exception('note was not found: {}'.format(note['id']))
2018-05-07 00:52:24 +00:00
2018-05-08 22:13:49 +00:00
for name, value in note['fields'].items():
if name in ankiNote:
ankiNote[name] = value
2018-05-07 00:52:24 +00:00
2018-05-08 22:13:49 +00:00
ankiNote.flush()
2016-05-21 22:10:12 +00:00
2018-03-31 21:40:01 +00:00
2018-05-07 00:52:24 +00:00
@api()
def addTags(self, notes, tags, add=True):
self.startEditing()
self.collection().tags.bulkAdd(notes, tags, add)
self.stopEditing()
2017-08-03 20:07:22 +00:00
2018-05-07 00:52:24 +00:00
@api()
def removeTags(self, notes, tags):
return self.addTags(notes, tags, False)
2018-05-07 00:52:24 +00:00
@api()
2018-01-14 11:26:37 +00:00
def getTags(self):
return self.collection().tags.all()
2018-05-07 00:52:24 +00:00
@api()
def suspend(self, cards, suspend=True):
for card in cards:
2018-05-07 00:52:24 +00:00
if self.suspended(card) == suspend:
cards.remove(card)
2018-05-07 00:52:24 +00:00
if len(cards) == 0:
return False
2018-05-07 00:52:24 +00:00
scheduler = self.scheduler()
self.startEditing()
if suspend:
scheduler.suspendCards(cards)
else:
scheduler.unsuspendCards(cards)
self.stopEditing()
return True
2018-03-31 21:40:01 +00:00
2018-05-07 00:52:24 +00:00
@api()
def unsuspend(self, cards):
self.suspend(cards, False)
2018-05-07 00:52:24 +00:00
@api()
def suspended(self, card):
2018-03-11 12:27:19 +00:00
card = self.collection().getCard(card)
return card.queue == -1
2018-03-31 21:40:01 +00:00
2018-05-07 00:52:24 +00:00
@api()
def areSuspended(self, cards):
suspended = []
for card in cards:
2018-05-07 00:52:24 +00:00
suspended.append(self.suspended(card))
return suspended
2018-05-07 00:52:24 +00:00
@api()
def areDue(self, cards):
due = []
for card in cards:
2018-05-07 00:52:24 +00:00
if self.findCards('cid:{} is:new'.format(card)):
due.append(True)
else:
2018-05-07 00:52:24 +00:00
date, ivl = self.collection().db.all('select id/1000.0, ivl from revlog where cid = ?', card)[-1]
if ivl >= -1200:
2018-06-03 15:26:33 +00:00
due.append(bool(self.findCards('cid:{} is:due'.format(card))))
else:
2018-05-07 00:52:24 +00:00
due.append(date - ivl <= time())
return due
2017-08-03 20:07:22 +00:00
2018-05-07 00:52:24 +00:00
@api()
2017-08-09 17:40:09 +00:00
def getIntervals(self, cards, complete=False):
intervals = []
for card in cards:
2018-05-07 00:52:24 +00:00
if self.findCards('cid:{} is:new'.format(card)):
intervals.append(0)
2018-05-07 00:52:24 +00:00
else:
interval = self.collection().db.list('select ivl from revlog where cid = ?', card)
if not complete:
interval = interval[-1]
intervals.append(interval)
2017-08-09 17:40:09 +00:00
return intervals
2018-05-07 00:52:24 +00:00
@api()
2016-05-21 22:10:12 +00:00
def modelNames(self):
2018-05-07 00:52:24 +00:00
return self.collection().models.allNames()
2016-05-21 22:10:12 +00:00
@api()
def createModel(self, modelName, inOrderFields, cardTemplates, css = None):
# https://github.com/dae/anki/blob/b06b70f7214fb1f2ce33ba06d2b095384b81f874/anki/stdmodels.py
if (len(inOrderFields) == 0):
raise Exception('Must provide at least one field for inOrderFields')
if (len(cardTemplates) == 0):
raise Exception('Must provide at least one card for cardTemplates')
if (modelName in self.collection().models.allNames()):
raise Exception('Model name already exists')
collection = self.collection()
mm = collection.models
# Generate new Note
m = mm.new(_(modelName))
# Create fields and add them to Note
for field in inOrderFields:
fm = mm.newField(_(field))
mm.addField(m, fm)
2019-03-07 18:19:35 +00:00
# Add shared css to model if exists. Use default otherwise
if (css is not None):
m['css'] = css
# Generate new card template(s)
cardCount = 1
for card in cardTemplates:
t = mm.newTemplate(_('Card ' + str(cardCount)))
cardCount += 1
t['qfmt'] = card['Front']
t['afmt'] = card['Back']
mm.addTemplate(m, t)
mm.add(m)
return m
2018-05-07 00:52:24 +00:00
@api()
2017-08-21 16:15:11 +00:00
def modelNamesAndIds(self):
models = {}
2018-05-07 00:52:24 +00:00
for model in self.modelNames():
models[model] = int(self.collection().models.byName(model)['id'])
2017-08-21 16:15:11 +00:00
return models
2018-05-07 00:52:24 +00:00
@api()
2017-07-01 19:49:44 +00:00
def modelNameFromId(self, modelId):
2018-05-07 00:52:24 +00:00
model = self.collection().models.get(modelId)
if model is None:
raise Exception('model was not found: {}'.format(modelId))
else:
return model['name']
2016-05-29 17:31:24 +00:00
2017-07-01 19:41:27 +00:00
2018-05-07 00:52:24 +00:00
@api()
2017-07-01 19:41:27 +00:00
def modelFieldNames(self, modelName):
2018-05-07 00:52:24 +00:00
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']]
2016-05-21 22:10:12 +00:00
2018-05-07 00:52:24 +00:00
@api()
2017-08-21 14:10:57 +00:00
def modelFieldsOnTemplates(self, modelName):
model = self.collection().models.byName(modelName)
2018-05-07 00:52:24 +00:00
if model is None:
raise Exception('model was not found: {}'.format(modelName))
2017-08-21 14:10:57 +00:00
2018-05-07 00:52:24 +00:00
templates = {}
for template in model['tmpls']:
fields = []
for side in ['qfmt', 'afmt']:
fieldsForSide = []
2017-08-21 14:10:57 +00:00
2018-05-07 00:52:24 +00:00
# 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]
2017-08-21 14:10:57 +00:00
2018-05-07 00:52:24 +00:00
# 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)
2017-08-21 14:10:57 +00:00
2018-05-07 00:52:24 +00:00
fields.append(fieldsForSide)
2017-08-21 14:10:57 +00:00
2018-05-07 00:52:24 +00:00
templates[template['name']] = fields
2017-08-21 14:10:57 +00:00
2018-05-07 00:52:24 +00:00
return templates
2017-08-21 14:10:57 +00:00
2018-05-07 00:52:24 +00:00
@api()
2017-07-01 19:49:44 +00:00
def deckNameFromId(self, deckId):
2018-05-07 00:52:24 +00:00
deck = self.collection().decks.get(deckId)
if deck is None:
raise Exception('deck was not found: {}'.format(deckId))
else:
return deck['name']
2017-07-01 19:41:27 +00:00
2018-05-07 00:52:24 +00:00
@api()
def findNotes(self, query=None):
2018-05-07 00:52:24 +00:00
if query is None:
return []
2018-05-07 00:52:24 +00:00
else:
return self.collection().findNotes(query)
2018-05-07 00:52:24 +00:00
@api()
def findCards(self, query=None):
2018-05-07 01:45:56 +00:00
if query is None:
return []
2018-05-07 00:52:24 +00:00
else:
return self.collection().findCards(query)
2018-03-31 21:40:01 +00:00
2018-05-07 00:52:24 +00:00
@api()
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}
2018-03-31 21:40:01 +00:00
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'],
2018-03-31 21:40:01 +00:00
'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.
2018-03-11 22:10:07 +00:00
# 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
2018-03-31 21:40:01 +00:00
2018-05-07 00:52:24 +00:00
@api()
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}
2018-03-31 21:40:01 +00:00
result.append({
'noteId': note.id,
'tags' : note.tags,
'fields': fields,
'modelName': model['name'],
2018-05-07 00:52:24 +00:00
'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.
2018-03-11 22:10:07 +00:00
# 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({})
2018-05-07 00:52:24 +00:00
return result
2019-02-26 17:28:36 +00:00
@api()
def deleteNotes(self, notes):
try:
self.collection().remNotes(notes)
finally:
self.stopEditing()
2017-08-13 08:02:59 +00:00
2017-08-13 09:05:56 +00:00
2018-05-07 00:52:24 +00:00
@api()
2017-08-09 18:05:00 +00:00
def cardsToNotes(self, cards):
return self.collection().db.list('select distinct nid from cards where id in ' + anki.utils.ids2str(cards))
2017-08-09 18:05:00 +00:00
2018-05-07 00:52:24 +00:00
@api()
def guiBrowse(self, query=None):
2017-05-28 22:54:28 +00:00
browser = aqt.dialogs.open('Browser', self.window())
2017-05-27 12:14:40 +00:00
browser.activateWindow()
2017-05-28 22:54:28 +00:00
if query is not None:
2017-05-27 12:14:40 +00:00
browser.form.searchEdit.lineEdit().setText(query)
if hasattr(browser, 'onSearch'):
browser.onSearch()
else:
browser.onSearchActivated()
2017-05-28 22:54:28 +00:00
2017-05-27 12:14:40 +00:00
return browser.model.cards
2018-05-07 00:52:24 +00:00
@api()
def guiAddCards(self, note=None):
2017-05-27 12:14:40 +00:00
if note is not None:
collection = self.collection()
deck = collection.decks.byName(note['deckName'])
if deck is None:
raise Exception('deck was not found: {}'.format(note['deckName']))
self.collection().decks.select(deck['id'])
2019-01-31 23:15:32 +00:00
savedMid = deck.pop('mid', None)
model = collection.models.byName(note['modelName'])
if model is None:
raise Exception('model was not found: {}'.format(note['modelName']))
self.collection().models.setCurrent(model)
self.collection().models.update(model)
2019-01-18 02:56:02 +00:00
closeAfterAdding = False
if note is not None and 'options' in note:
2019-01-18 02:56:02 +00:00
if 'closeAfterAdding' in note['options']:
closeAfterAdding = note['options']['closeAfterAdding']
if type(closeAfterAdding) is not bool:
raise Exception('option parameter \'closeAfterAdding\' must be boolean')
2019-01-31 23:15:32 +00:00
addCards = None
2019-01-24 06:07:53 +00:00
2019-01-18 02:56:02 +00:00
if closeAfterAdding:
2019-01-24 06:07:53 +00:00
2019-01-24 07:36:11 +00:00
randomString = ''.join(choice(ascii_letters) for _ in range(10))
2019-01-24 06:07:53 +00:00
windowName = 'AddCardsAndClose' + randomString
2019-01-24 07:36:11 +00:00
if ANKI21:
class AddCardsAndClose(aqt.addcards.AddCards):
2019-01-24 07:36:11 +00:00
def __init__(self, mw):
2019-05-02 16:33:24 +00:00
# the window must only reset if
# * function `onModelChange` has been called prior
# * window was newly opened
self.modelHasChanged = True
2019-01-24 07:36:11 +00:00
super().__init__(mw)
2019-05-02 16:33:24 +00:00
2019-01-24 07:36:11 +00:00
self.addButton.setText("Add and Close")
self.addButton.setShortcut(aqt.qt.QKeySequence("Ctrl+Return"))
2019-01-24 07:36:11 +00:00
def _addCards(self):
super()._addCards()
2019-01-24 08:04:52 +00:00
# if adding was successful it must mean it was added to the history of the window
if len(self.history):
self.reject()
2019-05-02 16:33:24 +00:00
def onModelChange(self):
if self.isActiveWindow():
super().onModelChange()
self.modelHasChanged = True
def onReset(self, model=None, keep=False):
if self.isActiveWindow() or self.modelHasChanged:
super().onReset(model, keep)
self.modelHasChanged = False
else:
# modelchoosers text is changed by a reset hook
# therefore we need to change it back manually
self.modelChooser.models.setText(self.editor.note.model()['name'])
self.modelHasChanged = False
2019-01-24 07:36:11 +00:00
def _reject(self):
savedMarkClosed = aqt.dialogs.markClosed
aqt.dialogs.markClosed = lambda _: savedMarkClosed(windowName)
super()._reject()
aqt.dialogs.markClosed = savedMarkClosed
else:
class AddCardsAndClose(aqt.addcards.AddCards):
def __init__(self, mw):
2019-05-02 16:33:24 +00:00
self.modelHasChanged = True
2019-01-24 07:36:11 +00:00
super(AddCardsAndClose, self).__init__(mw)
2019-05-02 16:33:24 +00:00
2019-01-24 07:36:11 +00:00
self.addButton.setText("Add and Close")
self.addButton.setShortcut(aqt.qt.QKeySequence("Ctrl+Return"))
def addCards(self):
super(AddCardsAndClose, self).addCards()
2019-01-24 08:04:52 +00:00
# if adding was successful it must mean it was added to the history of the window
if len(self.history):
self.reject()
2019-01-24 07:36:11 +00:00
2019-05-02 16:33:24 +00:00
def onModelChange(self):
if self.isActiveWindow():
super(AddCardsAndClose, self).onModelChange()
self.modelHasChanged = True
def onReset(self, model=None, keep=False):
if self.isActiveWindow() or self.modelHasChanged:
super(AddCardsAndClose, self).onReset(model, keep)
self.modelHasChanged = False
else:
self.modelChooser.models.setText(self.editor.note.model()['name'])
self.modelHasChanged = False
2019-01-24 07:36:11 +00:00
def reject(self):
savedClose = aqt.dialogs.close
aqt.dialogs.close = lambda _: savedClose(windowName)
super(AddCardsAndClose, self).reject()
aqt.dialogs.close = savedClose
2019-01-24 06:07:53 +00:00
aqt.dialogs._dialogs[windowName] = [AddCardsAndClose, None]
addCards = aqt.dialogs.open(windowName, self.window())
2017-05-27 12:14:40 +00:00
2019-01-31 23:15:32 +00:00
if savedMid:
deck['mid'] = savedMid
2019-01-24 06:07:53 +00:00
editor = addCards.editor
ankiNote = editor.note
if 'fields' in note:
for name, value in note['fields'].items():
if name in ankiNote:
ankiNote[name] = value
editor.loadNote()
if 'tags' in note:
ankiNote.tags = note['tags']
editor.updateTags()
2019-01-24 06:07:53 +00:00
# if Anki does not Focus, the window will not notice that the
# fields are actually filled
aqt.dialogs.open(windowName, self.window())
2019-01-24 07:36:11 +00:00
if ANKI21:
addCards.setAndFocusNote(editor.note)
2019-01-24 06:07:53 +00:00
elif note is not None:
currentWindow = aqt.dialogs._dialogs['AddCards'][1]
def openNewWindow():
addCards = aqt.dialogs.open('AddCards', self.window())
2019-01-31 23:15:32 +00:00
if savedMid:
deck['mid'] = savedMid
2019-01-24 06:07:53 +00:00
editor = addCards.editor
ankiNote = editor.note
# we have to fill out the card in the callback
# otherwise we can't assure, the new window is open
if 'fields' in note:
for name, value in note['fields'].items():
if name in ankiNote:
ankiNote[name] = value
editor.loadNote()
if 'tags' in note:
ankiNote.tags = note['tags']
editor.updateTags()
addCards.activateWindow()
2019-01-24 07:36:11 +00:00
aqt.dialogs.open('AddCards', self.window())
if ANKI21:
addCards.setAndFocusNote(editor.note)
2019-01-24 06:07:53 +00:00
if currentWindow is not None:
2019-01-31 23:15:32 +00:00
if ANKI21:
currentWindow.closeWithCallback(openNewWindow)
else:
currentWindow.reject()
openNewWindow()
2019-01-24 06:07:53 +00:00
else:
openNewWindow()
else:
addCards = aqt.dialogs.open('AddCards', self.window())
addCards.activateWindow()
2017-05-27 12:14:40 +00:00
2018-05-07 00:52:24 +00:00
@api()
2017-07-01 19:41:27 +00:00
def guiReviewActive(self):
2017-07-03 00:27:31 +00:00
return self.reviewer().card is not None and self.window().state == 'review'
2017-07-01 19:41:27 +00:00
2018-05-07 00:52:24 +00:00
@api()
2017-07-01 19:41:27 +00:00
def guiCurrentCard(self):
2017-07-01 19:49:44 +00:00
if not self.guiReviewActive():
2018-03-11 22:10:07 +00:00
raise Exception('Gui review is not currently active.')
2017-07-01 19:41:27 +00:00
reviewer = self.reviewer()
card = reviewer.card
2017-07-03 00:52:57 +00:00
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}
2017-06-29 04:17:11 +00:00
if card is not None:
2019-05-25 19:14:09 +00:00
buttonList = reviewer._answerButtonList()
return {
2017-07-01 19:41:27 +00:00
'cardId': card.id,
2017-07-03 00:52:57 +00:00
'fields': fields,
'fieldOrder': card.ord,
'question': card._getQA()['q'],
'answer': card._getQA()['a'],
2019-05-25 19:14:09 +00:00
'buttons': [b[0] for b in buttonList],
'nextReviews': [reviewer.mw.col.sched.nextIvlStr(reviewer.card, b[0], True) for b in buttonList],
'modelName': model['name'],
'deckName': self.deckNameFromId(card.did),
'css': model['css'],
'template': card.template()['name']
}
2017-06-29 04:17:11 +00:00
2018-05-07 00:52:24 +00:00
@api()
2017-08-16 12:04:05 +00:00
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
2017-08-16 12:04:05 +00:00
2018-03-31 21:40:01 +00:00
2018-05-07 00:52:24 +00:00
@api()
def guiShowQuestion(self):
2017-07-01 19:41:27 +00:00
if self.guiReviewActive():
self.reviewer()._showQuestion()
return True
else:
return False
2018-05-07 00:52:24 +00:00
@api()
def guiShowAnswer(self):
2017-07-01 19:41:27 +00:00
if self.guiReviewActive():
self.window().reviewer._showAnswer()
2017-07-01 19:41:27 +00:00
return True
else:
return False
2018-05-07 00:52:24 +00:00
@api()
2017-07-01 20:16:51 +00:00
def guiAnswerCard(self, ease):
2017-07-01 19:41:27 +00:00
if not self.guiReviewActive():
2017-06-29 04:17:11 +00:00
return False
2017-07-01 19:41:27 +00:00
reviewer = self.reviewer()
if reviewer.state != 'answer':
2017-06-29 04:17:11 +00:00
return False
2017-07-01 20:16:51 +00:00
if ease <= 0 or ease > self.scheduler().answerButtons(reviewer.card):
2017-06-29 04:17:11 +00:00
return False
2017-07-01 19:41:27 +00:00
reviewer._answerCard(ease)
return True
2017-07-03 00:27:31 +00:00
2018-05-07 00:52:24 +00:00
@api()
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
2017-07-03 00:27:31 +00:00
return False
2017-07-03 00:27:31 +00:00
2018-05-07 00:52:24 +00:00
@api()
def guiDeckBrowser(self):
2017-07-03 00:27:31 +00:00
self.window().moveToState('deckBrowser')
2018-05-07 00:52:24 +00:00
@api()
2017-07-03 00:27:31 +00:00
def guiDeckReview(self, name):
if self.guiDeckOverview(name):
self.window().moveToState('review')
return True
else:
return False
2018-03-31 21:40:01 +00:00
2018-05-07 00:52:24 +00:00
@api()
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.
2018-03-31 21:40:01 +00:00
2016-05-29 22:49:38 +00:00
2018-05-07 00:52:24 +00:00
@api()
def addNotes(self, notes):
results = []
for note in notes:
try:
2018-05-06 20:24:40 +00:00
results.append(self.addNote(note))
except Exception:
results.append(None)
2018-03-31 21:40:01 +00:00
return results
2018-03-31 21:40:01 +00:00
2018-05-07 00:52:24 +00:00
@api()
def canAddNotes(self, notes):
results = []
for note in notes:
2018-05-06 20:24:40 +00:00
results.append(self.canAddNote(note))
2018-03-31 21:40:01 +00:00
return results
2018-02-21 13:16:52 +00:00
2016-05-21 22:10:12 +00:00
#
2018-05-07 00:52:24 +00:00
# Entry
2016-05-21 22:10:12 +00:00
#
ac = AnkiConnect()