commit a67798d2b7940e79556bcedb1cb06a30ca65e3a9 Author: abearxiong Date: Fri Jun 6 15:10:15 2025 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8315ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,95 @@ +# Byte-compiled / optimized / profiling files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.install-record.txt +MANIFEST + +# PyInstaller +# Usually these files are created by pyinstaller command +build/ +dist/ +*.spec + +# Installer logs +pip-log.txt +pipwin.log + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# Django +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask +instance/ +.webassets-cache + +# Scrapy project settings +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# VSCode +.vscode/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..811a691 --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ +Copyright 2016-2019 Alex Yatskov + +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 . diff --git a/README-zh.md b/README-zh.md new file mode 100644 index 0000000..72b94ca --- /dev/null +++ b/README-zh.md @@ -0,0 +1,8415 @@ +# Anki-Connect + +Anki-Connect使外部应用程序(如Yomichan)能够通过简单的HTTP API与[Anki](https://apps.ankiweb.net/)进行通信。它的功能包括对用户卡片组执行查询、自动创建新卡片等。Anki-Connect与最新的稳定版(2.1.x)Anki兼容;旧版本(2.0.x及以下)不再受支持。 + +## 安装 + +安装过程与其他Anki插件类似,可以通过以下三个步骤完成: + +1. 在Anki中选择`工具` | `插件` | `获取插件...`,打开`安装插件`对话框。 +2. 在标有`代码`的文本框中输入[2055492159](https://ankiweb.net/shared/info/2055492159),然后按`确定`按钮继续。 +3. 当提示重启Anki时,请按照要求进行操作,以完成Anki-Connect的安装。 + +Anki必须在后台保持运行,以便其他应用程序能够使用Anki-Connect。您可以随时通过在浏览器中访问`localhost:8765`来验证Anki-Connect是否在运行。如果服务器正在运行,您将在浏览器窗口中看到`Anki-Connect`的消息。 + +### Windows用户注意事项 + +Windows用户可能会在Anki启动时看到防火墙提示对话框。这是因为Anki-Connect运行了本地HTTP服务器,以便其他应用程序能够连接到它。主应用程序Anki必须被允许通过防火墙,此插件才能正常运行。 + +### MacOS用户注意事项 + +从[Mac OS X Mavericks](https://en.wikipedia.org/wiki/OS_X_Mavericks)开始,操作系统引入了名为*App Nap*的功能。此功能会使某些已打开但不可见的应用程序进入挂起状态。由于此行为会导致Anki-Connect在前台显示其他窗口时停止工作,因此应为Anki禁用App Nap: + +1. 启动终端应用程序。 +2. 在终端窗口中执行以下命令: + ```bash + defaults write net.ankiweb.dtop NSAppSleepDisabled -bool true + defaults write net.ichi2.anki NSAppSleepDisabled -bool true + defaults write org.qt-project.Qt.QtWebEngineCore NSAppSleepDisabled -bool true + ``` +3. 重启Anki。 + +## 开发者应用程序接口 + +Anki-Connect通过易于使用的API向外部应用程序公开Anki的内部功能。安装后,只要启动Anki,该插件就会在8765端口启动HTTP服务器。其他应用程序(包括浏览器扩展)可以通过HTTP请求与其通信。 + +默认情况下,Anki-Connect只会将HTTP服务器绑定到`127.0.0.1`IP地址,因此您只能从运行它的同一主机访问它。如果您需要通过网络访问,可以在配置中更改绑定地址。进入工具->插件->AnkiConnect->配置,更改"webBindAddress"值。例如,您可以将其设置为`0.0.0.0`,以将其绑定到主机上的所有网络接口。这也需要重启Anki。 + +### 调用示例 + +每个请求都由一个包含`action`、`version`、上下文`params`和用于认证的`key`值(可选,默认可以省略)的JSON编码对象组成。Anki-Connect将返回一个包含两个字段的对象:`result`和`error`。`result`字段包含执行的API的返回值,而`error`字段是在API执行期间抛出的任何异常的描述(如果执行成功,则使用值`null`)。 + +*成功响应示例*: +```json +{"result": ["Default", "Filtered Deck 1"], "error": null} +``` + +*失败响应示例*: +```json +{"result": null, "error": "unsupported action"} +``` +```json +{"result": null, "error": "guiBrowse() got an unexpected keyword argument 'foobar'"} +``` + +为了与旧版本Anki-Connect设计的客户端兼容,如果请求中未提供`version`字段,版本将默认为4。此外,当提供的版本为4级或以下时,API响应将只包含`result`的值;没有`error`字段可用于错误处理。 + +您可以使用任何语言或工具向Anki-Connect发出请求,但下面包含了几个简单的示例作为参考。 + +#### Curl + +```bash +curl localhost:8765 -X POST -d '{"action": "deckNames", "version": 6}' +``` + +#### Powershell + +```powershell +(Invoke-RestMethod -Uri http://localhost:8765 -Method Post -Body '{"action": "deckNames", "version": 6}').result +``` + +#### Python + +```python +import json +import urllib.request + +def request(action, **params): + return {'action': action, 'params': params, 'version': 6} + +def invoke(action, **params): + requestJson = json.dumps(request(action, **params)).encode('utf-8') + response = json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8765', requestJson))) + 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'] + +invoke('createDeck', deck='test1') +result = invoke('deckNames') +print('got list of decks: {}'.format(result)) +``` + +#### JavaScript + +```javascript +function invoke(action, version, params={}) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.addEventListener('error', () => reject('failed to issue request')); + xhr.addEventListener('load', () => { + try { + const response = JSON.parse(xhr.responseText); + if (Object.getOwnPropertyNames(response).length != 2) { + throw 'response has an unexpected number of fields'; + } + if (!response.hasOwnProperty('error')) { + throw 'response is missing required error field'; + } + if (!response.hasOwnProperty('result')) { + throw 'response is missing required result field'; + } + if (response.error) { + throw response.error; + } + resolve(response.result); + } catch (e) { + reject(e); + } + }); + + xhr.open('POST', 'http://127.0.0.1:8765'); + xhr.send(JSON.stringify({action, version, params})); + }); +} + +await invoke('createDeck', 6, {deck: 'test1'}); +const result = await invoke('deckNames', 6); +console.log(`got list of decks: ${result}`); +``` + +### 认证 + +Anki-Connect支持要求认证以便进行API请求。 +默认情况下,此支持是*禁用*的,但可以通过在Anki-Config的设置(工具->插件->AnkiConnect->配置)中设置`apiKey`字段为所需的字符串来启用。 +如果您已经这样做,您应该会看到[`requestPermission`](#requestpermission) API请求返回`true`作为`requireApiKey`。 +然后您必须在任何进一步的API请求体中包含一个名为`key`的附加参数,其值必须与配置的API密钥匹配。 + +### 嘿,你能添加一个新的action来支持$FEATURE吗? + +Anki-Connect的主要目标是支持来自Yomichan浏览器扩展的实时闪卡创建。当前的API提供了所有必要的动作来实现这一点。我认识到Anki-Connect的角色已经从这一最初愿景演变,而且我很乐意审查新的功能请求。 + +话虽如此,*本项目采用自助服务模式*。如果你想要一个新功能,请创建一个PR。我会审查它,如果看起来不错,就会合并。*没有附带拉取请求的添加新功能的请求将不会得到处理*。确保你的拉取请求满足以下标准: + +* 尝试匹配周围代码的风格。 +* 有附带的文档和示例。 +* 有验证操作的附带测试。 +* 实现在其他应用中有用的功能。 + +## 支持的动作 + +当前支持的动作的文档按类别分类并在下面引用。请注意,已弃用的API将继续运行,尽管未在此页面上列出,只要您的请求标有对应API可用时的版本号。搜索参数传递给Anki,更多信息请查看文档:https://docs.ankiweb.net/searching.html + +* [卡片操作](#card-actions) +* [卡组操作](#deck-actions) +* [图形界面操作](#graphical-actions) +* [媒体操作](#media-actions) +* [杂项操作](#miscellaneous-actions) +* [模型操作](#model-actions) +* [笔记操作](#note-actions) +* [统计操作](#statistic-actions) + +--- + +### 卡片操作 + +#### `getEaseFactors` + +* 返回一个数组,包含给定卡片的简易度因子(按相同顺序)。 + +
+ 示例请求: + + ```json + { + "action": "getEaseFactors", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [4100, 3900], + "error": null + } + ``` +
+ +#### `setEaseFactors` + +* 通过卡片ID设置卡片的简易度因子;如果成功(所有卡片都存在)则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "setEaseFactors", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217], + "easeFactors": [4100, 3900] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [true, true], + "error": null + } + ``` +
+ + +#### `setSpecificValueOfCard` + +* 设置单个卡片的特定值。由于更改卡片的某些值可能会在数据库中造成混乱,一些键需要将参数"warning_check"设置为True。 + 这可用于设置卡片的标志、更改其简易度因子、更改筛选卡组中的复习顺序以及更改列"data"(目前显然未被anki使用),以及许多其他值。 + 可以在[AnkiDroid的wiki](https://github.com/ankidroid/Anki-Android/wiki/Database-Structure)上找到值的列表及其各自的用途解释。 + +
+ 示例请求: + + ```json + { + "action": "setSpecificValueOfCard", + "version": 6, + "params": { + "card": 1483959291685, + "keys": ["flags", "odue"], + "newValues": ["1", "-100"] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [true, true], + "error": null + } + ``` +
+ + +#### `suspend` + +* 通过卡片ID暂停卡片;如果成功(至少有一张卡片之前没有被暂停)则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "suspend", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `unsuspend` + +* 通过卡片ID取消暂停卡片;如果成功(至少有一张卡片之前被暂停)则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "unsuspend", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `suspended` + +* 通过ID检查卡片是否被暂停。如果被暂停则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "suspended", + "version": 6, + "params": { + "card": 1483959293217 + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `areSuspended` + +* 返回一个数组,表示每张给定卡片是否被暂停(按相同顺序)。如果卡片不存在,则返回`null`。 + +
+ 示例请求: + + ```json + { + "action": "areSuspended", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217, 1234567891234] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [false, true, null], + "error": null + } + ``` +
+ +#### `areDue` + +* 返回一个数组,表示每张给定卡片是否到期(按相同顺序)。*注意*:学习队列中有大间隔(超过20分钟)的卡片被视为未到期,直到其间隔时间过去为止,这与Anki在复习时对待它们的方式相匹配。 + +
+ 示例请求: + + ```json + { + "action": "areDue", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [false, true], + "error": null + } + ``` +
+ +#### `getIntervals` + +* 返回一个数组,包含每个给定卡片ID的最近间隔,或者当`complete`为`true`时,返回每个给定卡片ID的所有间隔的二维数组。负间隔以秒为单位,正间隔以天为单位。 + +
+ 示例请求1: + + ```json + { + "action": "getIntervals", + "version": 6, + "params": { + "cards": [1502298033753, 1502298036657] + } + } + ``` +
+ +
+ 示例结果1: + + ```json + { + "result": [-14400, 3], + "error": null + } + ``` +
+ +
+ 示例请求2: + + ```json + { + "action": "getIntervals", + "version": 6, + "params": { + "cards": [1502298033753, 1502298036657], + "complete": true + } + } + ``` +
+ +
+ 示例结果2: + + ```json + { + "result": [ + [-120, -180, -240, -300, -360, -14400], + [-120, -180, -240, -300, -360, -14400, 1, 3] + ], + "error": null + } + ``` +
+ +#### `findCards` + +* 返回给定查询的卡片ID数组。功能上与`guiBrowse`相同,但不使用GUI以获得更好的性能。 + +
+ 示例请求: + + ```json + { + "action": "findCards", + "version": 6, + "params": { + "query": "deck:current" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [1494723142483, 1494703460437, 1494703479525], + "error": null + } + ``` +
+ +#### `cardsToNotes` + +* 返回给定卡片ID的笔记ID的无序数组。对于具有相同笔记的卡片,ID在数组中只给出一次。 + +
+ 示例请求: + + ```json + { + "action": "cardsToNotes", + "version": 6, + "params": { + "cards": [1502098034045, 1502098034048, 1502298033753] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [1502098029797, 1502298025183], + "error": null + } + ``` +
+ +#### `cardsModTime` + +* 返回一个对象列表,包含每个卡片ID的修改时间。 + 此功能比执行`cardsInfo`快约15倍。 + +
+ 示例请求: + + ```json + { + "action": "cardsModTime", + "version": 6, + "params": { + "cards": [1498938915662, 1502098034048] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [ + { + "cardId": 1498938915662, + "mod": 1629454092 + } + ], + "error": null + } + ``` +
+ + +#### `cardsInfo` + +* 返回一个对象列表,包含每个卡片ID的卡片字段、正反面(包括CSS)、笔记类型、卡片所属的笔记、卡组名称、最后修改时间戳以及简易度和间隔。 + +
+ 示例请求: + + ```json + { + "action": "cardsInfo", + "version": 6, + "params": { + "cards": [1498938915662, 1502098034048] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [ + { + "answer": "back content", + "question": "front content", + "deckName": "Default", + "modelName": "Basic", + "fieldOrder": 1, + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} + }, + "css":"p {font-family:Arial;}", + "cardId": 1498938915662, + "interval": 16, + "note":1502298033753, + "ord": 1, + "type": 0, + "queue": 0, + "due": 1, + "reps": 1, + "lapses": 0, + "left": 6, + "mod": 1629454092 + }, + { + "answer": "back content", + "question": "front content", + "deckName": "Default", + "modelName": "Basic", + "fieldOrder": 0, + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} + }, + "css":"p {font-family:Arial;}", + "cardId": 1502098034048, + "interval": 23, + "note":1502298033753, + "ord": 1, + "type": 0, + "queue": 0, + "due": 1, + "reps": 1, + "lapses": 0, + "left": 6 + } + ], + "error": null + } + ``` +
+ +#### `forgetCards` + +* 忘记卡片,使卡片再次成为新卡片。 + +
+ 示例请求: + + ```json + { + "action": "forgetCards", + "version": 6, + "params": { + "cards": [1498938915662, 1502098034048] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `relearnCards` + +* 使卡片成为"重新学习"状态。 + +
+ 示例请求: + + ```json + { + "action": "relearnCards", + "version": 6, + "params": { + "cards": [1498938915662, 1502098034048] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `answerCards` + +* 回答卡片。简易度在1(重来)到4(简单)之间。将在回答前立即开始计时器。如果卡片存在则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "answerCards", + "version": 6, + "params": { + "answers": [ + { + "cardId": 1498938915662, + "ease": 2 + }, + { + "cardId": 1502098034048, + "ease": 4 + } + ] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [true, true], + "error": null + } + ``` +
+ +#### `setDueDate` + +* 设置到期日期。如果是新卡片,则将其转为复习卡片,并使其在特定日期到期。 + * 0 = 今天 + * 1! = 明天 + 将间隔更改为1 + * 3-7 = 随机选择3-7天 + +
+ 示例请求: + + ```json + { + "action": "setDueDate", + "version": 6, + "params": { + "cards": [1498938915662, 1502098034048], + "days": "3-7" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +--- + +### 卡组操作 + +#### `deckNames` + +* 获取当前用户的完整卡组名称列表。 + +
+ 示例请求: + + ```json + { + "action": "deckNames", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": ["Default"], + "error": null + } + ``` +
+ +#### `deckNamesAndIds` + +* 获取当前用户的完整卡组名称及其对应ID的列表。 + +
+ 示例请求: + + ```json + { + "action": "deckNamesAndIds", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": {"Default": 1}, + "error": null + } + ``` +
+ +#### `getDecks` + +* 接受一个卡片ID数组,并返回一个对象,其中每个卡组名称作为键,其值是属于该卡组的给定卡片数组。 + +
+ 示例请求: + + ```json + { + "action": "getDecks", + "version": 6, + "params": { + "cards": [1502298036657, 1502298033753, 1502032366472] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": { + "Default": [1502032366472], + "Japanese::JLPT N3": [1502298036657, 1502298033753] + }, + "error": null + } + ``` +
+ +#### `createDeck` + +* 创建一个新的空卡组。不会覆盖同名的已存在卡组。 + +
+ 示例请求: + + ```json + { + "action": "createDeck", + "version": 6, + "params": { + "deck": "Japanese::Tokyo" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": 1519323742721, + "error": null + } + ``` +
+ +#### `changeDeck` + +* 将具有给定ID的卡片移动到不同的卡组,如果卡组尚不存在则创建它。 + +
+ 示例请求: + + ```json + { + "action": "changeDeck", + "version": 6, + "params": { + "cards": [1502098034045, 1502098034048, 1502298033753], + "deck": "Japanese::JLPT N3" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `deleteDecks` + +* 删除具有给定名称的卡组。 + 参数`cardsToo` *必须*被指定并设置为`true`。 + +
+ 示例请求: + + ```json + { + "action": "deleteDecks", + "version": 6, + "params": { + "decks": ["Japanese::JLPT N5", "Easy Spanish"], + "cardsToo": true + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `getDeckConfig` + +* 获取给定卡组的配置组对象。 + +
+ 示例请求: + + ```json + { + "action": "getDeckConfig", + "version": 6, + "params": { + "deck": "Default" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": { + "lapse": { + "leechFails": 8, + "delays": [10], + "minInt": 1, + "leechAction": 0, + "mult": 0 + }, + "dyn": false, + "autoplay": true, + "mod": 1502970872, + "id": 1, + "maxTaken": 60, + "new": { + "bury": true, + "order": 1, + "initialFactor": 2500, + "perDay": 20, + "delays": [1, 10], + "separate": true, + "ints": [1, 4, 7] + }, + "name": "Default", + "rev": { + "bury": true, + "ivlFct": 1, + "ease4": 1.3, + "maxIvl": 36500, + "perDay": 100, + "minSpace": 1, + "fuzz": 0.05 + }, + "timer": 0, + "replayq": true, + "usn": -1 + }, + "error": null + } + ``` +
+ +#### `saveDeckConfig` + +* 保存给定的配置组,成功时返回`true`,如果配置组的ID无效(例如不存在)则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "saveDeckConfig", + "version": 6, + "params": { + "config": { + "lapse": { + "leechFails": 8, + "delays": [10], + "minInt": 1, + "leechAction": 0, + "mult": 0 + }, + "dyn": false, + "autoplay": true, + "mod": 1502970872, + "id": 1, + "maxTaken": 60, + "new": { + "bury": true, + "order": 1, + "initialFactor": 2500, + "perDay": 20, + "delays": [1, 10], + "separate": true, + "ints": [1, 4, 7] + }, + "name": "Default", + "rev": { + "bury": true, + "ivlFct": 1, + "ease4": 1.3, + "maxIvl": 36500, + "perDay": 100, + "minSpace": 1, + "fuzz": 0.05 + }, + "timer": 0, + "replayq": true, + "usn": -1 + } + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `setDeckConfigId` + +* 将给定卡组的配置组更改为具有给定ID的配置组。成功时返回`true`,如果给定的配置组或任何给定的卡组不存在则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "setDeckConfigId", + "version": 6, + "params": { + "decks": ["Default"], + "configId": 1 + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `cloneDeckConfigId` + +* 使用给定的名称创建一个新的配置组,从具有给定ID的组克隆,或者如果未指定,则从默认组克隆。返回新配置组的ID,或者如果指定的要克隆的组不存在,则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "cloneDeckConfigId", + "version": 6, + "params": { + "name": "Copy of Default", + "cloneFrom": 1 + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": 1502972374573, + "error": null + } + ``` +
+ +#### `removeDeckConfigId` + +* 移除具有给定ID的配置组,如果成功则返回`true`,如果尝试移除默认配置组(ID = 1)或不存在的配置组则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "removeDeckConfigId", + "version": 6, + "params": { + "configId": 1502972374573 + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `getDeckStats` + +* 获取给定卡组的统计信息,如总卡片数和到期卡片数。 + +
+ 示例请求: + + ```json + { + "action": "getDeckStats", + "version": 6, + "params": { + "decks": ["Japanese::JLPT N5", "Easy Spanish"] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": { + "1651445861967": { + "deck_id": 1651445861967, + "name": "Japanese::JLPT N5", + "new_count": 20, + "learn_count": 0, + "review_count": 0, + "total_in_deck": 1506 + }, + "1651445861960": { + "deck_id": 1651445861960, + "name": "Easy Spanish", + "new_count": 26, + "learn_count": 10, + "review_count": 5, + "total_in_deck": 852 + } + }, + "error": null + } + ``` +
+ +--- + +### 图形界面操作 + +#### `guiBrowse` + +* 调用*卡片浏览器*对话框并搜索给定查询。返回找到的卡片标识符数组。查询语法[在此处有文档](https://docs.ankiweb.net/searching.html)。 + + 可选地,可以提供`reorderCards`属性来重新排序*卡片浏览器*中显示的卡片。 + 这是一个包含`order`和`columnId`对象的数组。`order`可以是`ascending`或`descending`,而`columnId`可以是几个列标识符之一(如在[Anki源代码](https://github.com/ankitects/anki/blob/main/rslib/src/browser_table.rs)中记录的)。 + 指定的列需要在*卡片浏览器*中可见。 + +
+ 示例请求: + + ```json + { + "action": "guiBrowse", + "version": 6, + "params": { + "query": "deck:current", + "reorderCards": { + "order": "descending", + "columnId": "noteCrt" + } + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [1494723142483, 1494703460437, 1494703479525], + "error": null + } + ``` +
+ +#### `guiSelectCard` + +* 找到*卡片浏览器*对话框的打开实例,并根据卡片标识符选择一张卡片。 + 如果*卡片浏览器*是打开的,返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "guiSelectCard", + "version": 6, + "params": { + "card": 1494723142483 + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiSelectedNotes` + +* 找到*卡片浏览器*对话框的打开实例,并返回选中笔记的标识符数组。如果浏览器未打开,则返回空列表。 + +
+ 示例请求: + + ```json + { + "action": "guiSelectedNotes", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [1494723142483, 1494703460437, 1494703479525], + "error": null + } + ``` +
+ +#### `guiAddCards` + +* 调用*添加卡片*对话框,使用给定的卡组和模型预设笔记,带有提供的字段值和标签。 + 多次调用会关闭旧窗口并_重新打开窗口_,使用新提供的值。 + + 可以通过`audio`、`video`和`picture`键将音频、视频和图片文件嵌入到字段中。 + 请参考`addNote`和`storeMediaFile`的文档,了解这些字段的说明。 + + 结果是如果用户选择确认*添加卡片*对话框,将添加的笔记的ID。 + +
+ 示例请求: + + ```json + { + "action": "guiAddCards", + "version": 6, + "params": { + "note": { + "deckName": "Default", + "modelName": "Cloze", + "fields": { + "Text": "The capital of Romania is {{c1::Bucharest}}", + "Extra": "Romania is a country in Europe" + }, + "tags": [ + "countries" + ], + "picture": [{ + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/13/EU-Romania.svg/285px-EU-Romania.svg.png", + "filename": "romania.png", + "fields": [ + "Extra" + ] + }] + } + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": 1496198395707, + "error": null + } + ``` +
+ +#### `guiEditNote` + +* 打开*编辑*对话框,显示对应于给定笔记ID的笔记。 + 该对话框类似于*编辑当前*对话框,但: + + * 有一个预览按钮,用于预览笔记的卡片 + * 有一个浏览按钮,用于打开浏览器并显示这些卡片 + * 有上一个/后退按钮,用于导航对话框的历史 + * 没有带有关闭按钮的栏 + +
+ 示例请求: + + ```json + { + "action": "guiEditNote", + "version": 6, + "params": { + "note": 1649198355435 + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `guiCurrentCard` + +* 返回有关当前卡片的信息,如果不在复习模式,则返回`null`。 + +
+ 示例请求: + + ```json + { + "action": "guiCurrentCard", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": { + "answer": "back content", + "question": "front content", + "deckName": "Default", + "modelName": "Basic", + "fieldOrder": 0, + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} + }, + "template": "Forward", + "cardId": 1498938915662, + "buttons": [1, 2, 3], + "nextReviews": ["<1m", "<10m", "4d"] + }, + "error": null + } + ``` +
+ +#### `guiStartCardTimer` + +* 启动或重置当前卡片的`timerStarted`值。这对于将开始时间推迟到通过API显示卡片时很有用,从而在调用`guiAnswerCard`时使记录的回答卡片所花费的时间更准确。 + +
+ 示例请求: + + ```json + { + "action": "guiStartCardTimer", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiShowQuestion` + +* 显示当前卡片的问题文本;如果处于复习模式则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "guiShowQuestion", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiShowAnswer` + +* 显示当前卡片的答案文本;如果处于复习模式则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "guiShowAnswer", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiAnswerCard` + +* 回答当前卡片;如果成功则返回`true`,否则返回`false`。注意,在Anki接受任何答案之前,必须先显示当前卡片的答案。 + +
+ 示例请求: + + ```json + { + "action": "guiAnswerCard", + "version": 6, + "params": { + "ease": 1 + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiUndo` + +* 撤销最后一个动作/卡片;如果成功则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "guiUndo", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiDeckOverview` + +* 为具有给定名称的卡组打开*卡组概览*对话框;如果成功则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "guiDeckOverview", + "version": 6, + "params": { + "name": "Default" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiDeckBrowser` + +* 打开*卡组浏览器*对话框。 + +
+ 示例请求: + + ```json + { + "action": "guiDeckBrowser", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `guiDeckReview` + +* 开始复习具有给定名称的卡组;如果成功则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "guiDeckReview", + "version": 6, + "params": { + "name": "Default" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiImportFile` + +* 调用*导入...(Ctrl+Shift+I)*对话框,并可选择提供文件路径。弹出对话框供用户审核导入。支持Anki支持的所有文件类型。如果未提供路径,则显示打开文件对话框。在Windows上的路径中必须使用正斜杠。仅支持Anki 2.1.52+。 + +
+ 示例请求: + + ```json + { + "action": "guiImportFile", + "version": 6, + "params": { + "path": "C:/Users/Desktop/cards.txt" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `guiExitAnki` + +* 安排一个请求来优雅地关闭Anki。此操作是异步的,因此它会立即返回,而不会等待Anki进程实际终止。 + +
+ 示例请求: + + ```json + { + "action": "guiExitAnki", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `guiCheckDatabase` + +* 请求进行数据库检查,但立即返回而不等待检查完成。因此,即使在数据库检查过程中检测到错误,此操作也将始终返回`true`。 + +
+ 示例请求: + + ```json + { + "action": "guiCheckDatabase", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +--- + +### 媒体操作 + +#### `storeMediaFile` + +* 将具有指定base64编码内容的文件存储在媒体文件夹中。或者,您可以指定绝对文件路径,或者从中下载文件的URL。如果提供了`data`、`path`和`url`中的多个,将首先使用`data`字段,然后是`path`,最后是`url`。为了防止Anki删除不被任何卡片使用的文件(例如配置文件),请在文件名前加下划线。这些文件仍然会同步到AnkiWeb。 + 默认情况下,将删除任何同名的现有文件。设置`deleteExisting`为false可以通过[让Anki为新文件提供非冲突的名称](https://github.com/ankitects/anki/blob/aeba725d3ea9628c73300648f748140db3fdd5ed/rslib/src/media/files.rs#L194)来防止这种情况。 + +
+ 示例请求(相对路径): + + ```json + { + "action": "storeMediaFile", + "version": 6, + "params": { + "filename": "_hello.txt", + "data": "SGVsbG8sIHdvcmxkIQ==" + } + } + ``` + + *`_hello.txt`的内容*: + + ``` + Hello world! + ``` +
+ +
+ 示例结果(相对路径): + + ```json + { + "result": "_hello.txt", + "error": null + } + ``` +
+ +
+ 示例请求(绝对路径): + + ```json + { + "action": "storeMediaFile", + "version": 6, + "params": { + "filename": "_hello.txt", + "path": "/path/to/file" + } + } + ``` +
+ +
+ 示例结果(绝对路径): + + ```json + { + "result": "_hello.txt", + "error": null + } + ``` +
+ +
+ 示例请求(url): + + ```json + { + "action": "storeMediaFile", + "version": 6, + "params": { + "filename": "_hello.txt", + "url": "https://url.to.file" + } + } + ``` +
+ +
+ 示例结果(url): + + ```json + { + "result": "_hello.txt", + "error": null + } + ``` +
+ +#### `retrieveMediaFile` + +* 检索指定文件的base64编码内容,如果文件不存在则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "retrieveMediaFile", + "version": 6, + "params": { + "filename": "_hello.txt" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": "SGVsbG8sIHdvcmxkIQ==", + "error": null + } + ``` +
+ +#### `getMediaFilesNames` + +* 获取与模式匹配的媒体文件名。默认返回所有名称。 + +
+ 示例请求: + + ```json + { + "action": "getMediaFilesNames", + "version": 6, + "params": { + "pattern": "_hell*.txt" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": ["_hello.txt"], + "error": null + } + ``` +
+ +#### `getMediaDirPath` + +* 获取当前打开的配置文件的`collection.media`文件夹的完整路径。 + +
+ 示例请求: + + ```json + { + "action": "getMediaDirPath", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": "/home/user/.local/share/Anki2/Main/collection.media", + "error": null + } + ``` +
+ +#### `deleteMediaFile` + +* 删除媒体文件夹中的指定文件。 + +
+ 示例请求: + + ```json + { + "action": "deleteMediaFile", + "version": 6, + "params": { + "filename": "_hello.txt" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +--- + +### 杂项操作 + +#### `requestPermission` + +* 请求使用此插件公开的API的权限。此方法不需要API密钥,是唯一接受来自任何来源的请求的方法;其他方法只接受来自受信任来源的请求,这些来源列在插件配置的`webCorsOriginList`下。默认情况下,`localhost`是受信任的。 + + 从不受信任的来源调用此方法将在Anki中显示一个弹出窗口,询问用户是否允许您的来源使用API;来自受信任来源的调用将返回结果而不显示弹出窗口。 + 在拒绝许可时,用户还可以选择忽略来自该来源的进一步许可请求。这些来源最终会出现在`ignoreOriginList`中,可通过插件配置进行编辑。 + + 结果始终包含`permission`字段,该字段反过来包含字符串`granted`或`denied`,对应于您的来源是否受信任。如果您的来源受信任,还将返回字段`requireApiKey`(如果需要则为`true`)和`version`。 + + 这应该是您进行的第一个调用,以确保您的应用程序和Anki-Connect能够相互正确通信。新版本的Anki-Connect向后兼容;只要您使用在报告的Anki-Connect版本或更早版本中可用的操作,一切都应该正常工作。 + +
+ 示例请求: + + ```json + { + "action": "requestPermission", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": { + "permission": "granted", + "requireApiKey": false, + "version": 6 + }, + "error": null + } + ``` + + ```json + { + "result": { + "permission": "denied" + }, + "error": null + } + ``` +
+ +#### `version` + +* 获取此插件公开的API的版本。目前定义了版本`1`到`6`。 + +
+ 示例请求: + + ```json + { + "action": "version", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": 6, + "error": null + } + ``` +
+ + +#### `apiReflect` + +* 获取有关可用AnkiConnect API的信息。请求支持以下参数: + + +# Anki-Connect + +Anki-Connect使外部应用程序(如Yomichan)能够通过简单的HTTP API与[Anki](https://apps.ankiweb.net/)进行通信。它的功能包括对用户卡片组执行查询、自动创建新卡片等。Anki-Connect与最新的稳定版(2.1.x)Anki兼容;旧版本(2.0.x及以下)不再受支持。 + +## 安装 + +安装过程与其他Anki插件类似,可以通过以下三个步骤完成: + +1. 在Anki中选择`工具` | `插件` | `获取插件...`,打开`安装插件`对话框。 +2. 在标有`代码`的文本框中输入[2055492159](https://ankiweb.net/shared/info/2055492159),然后按`确定`按钮继续。 +3. 当提示重启Anki时,请按照要求进行操作,以完成Anki-Connect的安装。 + +Anki必须在后台保持运行,以便其他应用程序能够使用Anki-Connect。您可以随时通过在浏览器中访问`localhost:8765`来验证Anki-Connect是否在运行。如果服务器正在运行,您将在浏览器窗口中看到`Anki-Connect`的消息。 + +### Windows用户注意事项 + +Windows用户可能会在Anki启动时看到防火墙提示对话框。这是因为Anki-Connect运行了本地HTTP服务器,以便其他应用程序能够连接到它。主应用程序Anki必须被允许通过防火墙,此插件才能正常运行。 + +### MacOS用户注意事项 + +从[Mac OS X Mavericks](https://en.wikipedia.org/wiki/OS_X_Mavericks)开始,操作系统引入了名为*App Nap*的功能。此功能会使某些已打开但不可见的应用程序进入挂起状态。由于此行为会导致Anki-Connect在前台显示其他窗口时停止工作,因此应为Anki禁用App Nap: + +1. 启动终端应用程序。 +2. 在终端窗口中执行以下命令: + ```bash + defaults write net.ankiweb.dtop NSAppSleepDisabled -bool true + defaults write net.ichi2.anki NSAppSleepDisabled -bool true + defaults write org.qt-project.Qt.QtWebEngineCore NSAppSleepDisabled -bool true + ``` +3. 重启Anki。 + +## 开发者应用程序接口 + +Anki-Connect通过易于使用的API向外部应用程序公开Anki的内部功能。安装后,只要启动Anki,该插件就会在8765端口启动HTTP服务器。其他应用程序(包括浏览器扩展)可以通过HTTP请求与其通信。 + +默认情况下,Anki-Connect只会将HTTP服务器绑定到`127.0.0.1`IP地址,因此您只能从运行它的同一主机访问它。如果您需要通过网络访问,可以在配置中更改绑定地址。进入工具->插件->AnkiConnect->配置,更改"webBindAddress"值。例如,您可以将其设置为`0.0.0.0`,以将其绑定到主机上的所有网络接口。这也需要重启Anki。 + +### 调用示例 + +每个请求都由一个包含`action`、`version`、上下文`params`和用于认证的`key`值(可选,默认可以省略)的JSON编码对象组成。Anki-Connect将返回一个包含两个字段的对象:`result`和`error`。`result`字段包含执行的API的返回值,而`error`字段是在API执行期间抛出的任何异常的描述(如果执行成功,则使用值`null`)。 + +*成功响应示例*: +```json +{"result": ["Default", "Filtered Deck 1"], "error": null} +``` + +*失败响应示例*: +```json +{"result": null, "error": "unsupported action"} +``` +```json +{"result": null, "error": "guiBrowse() got an unexpected keyword argument 'foobar'"} +``` + +为了与旧版本Anki-Connect设计的客户端兼容,如果请求中未提供`version`字段,版本将默认为4。此外,当提供的版本为4级或以下时,API响应将只包含`result`的值;没有`error`字段可用于错误处理。 + +您可以使用任何语言或工具向Anki-Connect发出请求,但下面包含了几个简单的示例作为参考。 + +#### Curl + +```bash +curl localhost:8765 -X POST -d '{"action": "deckNames", "version": 6}' +``` + +#### Powershell + +```powershell +(Invoke-RestMethod -Uri http://localhost:8765 -Method Post -Body '{"action": "deckNames", "version": 6}').result +``` + +#### Python + +```python +import json +import urllib.request + +def request(action, **params): + return {'action': action, 'params': params, 'version': 6} + +def invoke(action, **params): + requestJson = json.dumps(request(action, **params)).encode('utf-8') + response = json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8765', requestJson))) + 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'] + +invoke('createDeck', deck='test1') +result = invoke('deckNames') +print('got list of decks: {}'.format(result)) +``` + +#### JavaScript + +```javascript +function invoke(action, version, params={}) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.addEventListener('error', () => reject('failed to issue request')); + xhr.addEventListener('load', () => { + try { + const response = JSON.parse(xhr.responseText); + if (Object.getOwnPropertyNames(response).length != 2) { + throw 'response has an unexpected number of fields'; + } + if (!response.hasOwnProperty('error')) { + throw 'response is missing required error field'; + } + if (!response.hasOwnProperty('result')) { + throw 'response is missing required result field'; + } + if (response.error) { + throw response.error; + } + resolve(response.result); + } catch (e) { + reject(e); + } + }); + + xhr.open('POST', 'http://127.0.0.1:8765'); + xhr.send(JSON.stringify({action, version, params})); + }); +} + +await invoke('createDeck', 6, {deck: 'test1'}); +const result = await invoke('deckNames', 6); +console.log(`got list of decks: ${result}`); +``` + +### 认证 + +Anki-Connect支持要求认证以便进行API请求。 +默认情况下,此支持是*禁用*的,但可以通过在Anki-Config的设置(工具->插件->AnkiConnect->配置)中设置`apiKey`字段为所需的字符串来启用。 +如果您已经这样做,您应该会看到[`requestPermission`](#requestpermission) API请求返回`true`作为`requireApiKey`。 +然后您必须在任何进一步的API请求体中包含一个名为`key`的附加参数,其值必须与配置的API密钥匹配。 + +### 嘿,你能添加一个新的action来支持$FEATURE吗? + +Anki-Connect的主要目标是支持来自Yomichan浏览器扩展的实时闪卡创建。当前的API提供了所有必要的动作来实现这一点。我认识到Anki-Connect的角色已经从这一最初愿景演变,而且我很乐意审查新的功能请求。 + +话虽如此,*本项目采用自助服务模式*。如果你想要一个新功能,请创建一个PR。我会审查它,如果看起来不错,就会合并。*没有附带拉取请求的添加新功能的请求将不会得到处理*。确保你的拉取请求满足以下标准: + +* 尝试匹配周围代码的风格。 +* 有附带的文档和示例。 +* 有验证操作的附带测试。 +* 实现在其他应用中有用的功能。 + +## 支持的动作 + +当前支持的动作的文档按类别分类并在下面引用。请注意,已弃用的API将继续运行,尽管未在此页面上列出,只要您的请求标有对应API可用时的版本号。搜索参数传递给Anki,更多信息请查看文档:https://docs.ankiweb.net/searching.html + +* [卡片操作](#card-actions) +* [卡组操作](#deck-actions) +* [图形界面操作](#graphical-actions) +* [媒体操作](#media-actions) +* [杂项操作](#miscellaneous-actions) +* [模型操作](#model-actions) +* [笔记操作](#note-actions) +* [统计操作](#statistic-actions) + +--- + +### 卡片操作 + +#### `getEaseFactors` + +* 返回一个数组,包含给定卡片的简易度因子(按相同顺序)。 + +
+ 示例请求: + + ```json + { + "action": "getEaseFactors", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [4100, 3900], + "error": null + } + ``` +
+ +#### `setEaseFactors` + +* 通过卡片ID设置卡片的简易度因子;如果成功(所有卡片都存在)则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "setEaseFactors", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217], + "easeFactors": [4100, 3900] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [true, true], + "error": null + } + ``` +
+ + +#### `setSpecificValueOfCard` + +* 设置单个卡片的特定值。由于更改卡片的某些值可能会在数据库中造成混乱,一些键需要将参数"warning_check"设置为True。 + 这可用于设置卡片的标志、更改其简易度因子、更改筛选卡组中的复习顺序以及更改列"data"(目前显然未被anki使用),以及许多其他值。 + 可以在[AnkiDroid的wiki](https://github.com/ankidroid/Anki-Android/wiki/Database-Structure)上找到值的列表及其各自的用途解释。 + +
+ 示例请求: + + ```json + { + "action": "setSpecificValueOfCard", + "version": 6, + "params": { + "card": 1483959291685, + "keys": ["flags", "odue"], + "newValues": ["1", "-100"] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [true, true], + "error": null + } + ``` +
+ + +#### `suspend` + +* 通过卡片ID暂停卡片;如果成功(至少有一张卡片之前没有被暂停)则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "suspend", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `unsuspend` + +* 通过卡片ID取消暂停卡片;如果成功(至少有一张卡片之前被暂停)则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "unsuspend", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `suspended` + +* 通过ID检查卡片是否被暂停。如果被暂停则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "suspended", + "version": 6, + "params": { + "card": 1483959293217 + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `areSuspended` + +* 返回一个数组,表示每张给定卡片是否被暂停(按相同顺序)。如果卡片不存在,则返回`null`。 + +
+ 示例请求: + + ```json + { + "action": "areSuspended", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217, 1234567891234] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [false, true, null], + "error": null + } + ``` +
+ +#### `areDue` + +* 返回一个数组,表示每张给定卡片是否到期(按相同顺序)。*注意*:学习队列中有大间隔(超过20分钟)的卡片被视为未到期,直到其间隔时间过去为止,这与Anki在复习时对待它们的方式相匹配。 + +
+ 示例请求: + + ```json + { + "action": "areDue", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [false, true], + "error": null + } + ``` +
+ +#### `getIntervals` + +* 返回一个数组,包含每个给定卡片ID的最近间隔,或者当`complete`为`true`时,返回每个给定卡片ID的所有间隔的二维数组。负间隔以秒为单位,正间隔以天为单位。 + +
+ 示例请求1: + + ```json + { + "action": "getIntervals", + "version": 6, + "params": { + "cards": [1502298033753, 1502298036657] + } + } + ``` +
+ +
+ 示例结果1: + + ```json + { + "result": [-14400, 3], + "error": null + } + ``` +
+ +
+ 示例请求2: + + ```json + { + "action": "getIntervals", + "version": 6, + "params": { + "cards": [1502298033753, 1502298036657], + "complete": true + } + } + ``` +
+ +
+ 示例结果2: + + ```json + { + "result": [ + [-120, -180, -240, -300, -360, -14400], + [-120, -180, -240, -300, -360, -14400, 1, 3] + ], + "error": null + } + ``` +
+ +#### `findCards` + +* 返回给定查询的卡片ID数组。功能上与`guiBrowse`相同,但不使用GUI以获得更好的性能。 + +
+ 示例请求: + + ```json + { + "action": "findCards", + "version": 6, + "params": { + "query": "deck:current" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [1494723142483, 1494703460437, 1494703479525], + "error": null + } + ``` +
+ +#### `cardsToNotes` + +* 返回给定卡片ID的笔记ID的无序数组。对于具有相同笔记的卡片,ID在数组中只给出一次。 + +
+ 示例请求: + + ```json + { + "action": "cardsToNotes", + "version": 6, + "params": { + "cards": [1502098034045, 1502098034048, 1502298033753] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [1502098029797, 1502298025183], + "error": null + } + ``` +
+ +#### `cardsModTime` + +* 返回一个对象列表,包含每个卡片ID的修改时间。 + 此功能比执行`cardsInfo`快约15倍。 + +
+ 示例请求: + + ```json + { + "action": "cardsModTime", + "version": 6, + "params": { + "cards": [1498938915662, 1502098034048] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [ + { + "cardId": 1498938915662, + "mod": 1629454092 + } + ], + "error": null + } + ``` +
+ + +#### `cardsInfo` + +* 返回一个对象列表,包含每个卡片ID的卡片字段、正反面(包括CSS)、笔记类型、卡片所属的笔记、卡组名称、最后修改时间戳以及简易度和间隔。 + +
+ 示例请求: + + ```json + { + "action": "cardsInfo", + "version": 6, + "params": { + "cards": [1498938915662, 1502098034048] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [ + { + "answer": "back content", + "question": "front content", + "deckName": "Default", + "modelName": "Basic", + "fieldOrder": 1, + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} + }, + "css":"p {font-family:Arial;}", + "cardId": 1498938915662, + "interval": 16, + "note":1502298033753, + "ord": 1, + "type": 0, + "queue": 0, + "due": 1, + "reps": 1, + "lapses": 0, + "left": 6, + "mod": 1629454092 + }, + { + "answer": "back content", + "question": "front content", + "deckName": "Default", + "modelName": "Basic", + "fieldOrder": 0, + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} + }, + "css":"p {font-family:Arial;}", + "cardId": 1502098034048, + "interval": 23, + "note":1502298033753, + "ord": 1, + "type": 0, + "queue": 0, + "due": 1, + "reps": 1, + "lapses": 0, + "left": 6 + } + ], + "error": null + } + ``` +
+ +#### `forgetCards` + +* 忘记卡片,使卡片再次成为新卡片。 + +
+ 示例请求: + + ```json + { + "action": "forgetCards", + "version": 6, + "params": { + "cards": [1498938915662, 1502098034048] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `relearnCards` + +* 使卡片成为"重新学习"状态。 + +
+ 示例请求: + + ```json + { + "action": "relearnCards", + "version": 6, + "params": { + "cards": [1498938915662, 1502098034048] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `answerCards` + +* 回答卡片。简易度在1(重来)到4(简单)之间。将在回答前立即开始计时器。如果卡片存在则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "answerCards", + "version": 6, + "params": { + "answers": [ + { + "cardId": 1498938915662, + "ease": 2 + }, + { + "cardId": 1502098034048, + "ease": 4 + } + ] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [true, true], + "error": null + } + ``` +
+ +#### `setDueDate` + +* 设置到期日期。如果是新卡片,则将其转为复习卡片,并使其在特定日期到期。 + * 0 = 今天 + * 1! = 明天 + 将间隔更改为1 + * 3-7 = 随机选择3-7天 + +
+ 示例请求: + + ```json + { + "action": "setDueDate", + "version": 6, + "params": { + "cards": [1498938915662, 1502098034048], + "days": "3-7" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +--- + +### 卡组操作 + +#### `deckNames` + +* 获取当前用户的完整卡组名称列表。 + +
+ 示例请求: + + ```json + { + "action": "deckNames", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": ["Default"], + "error": null + } + ``` +
+ +#### `deckNamesAndIds` + +* 获取当前用户的完整卡组名称及其对应ID的列表。 + +
+ 示例请求: + + ```json + { + "action": "deckNamesAndIds", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": {"Default": 1}, + "error": null + } + ``` +
+ +#### `getDecks` + +* 接受一个卡片ID数组,并返回一个对象,其中每个卡组名称作为键,其值是属于该卡组的给定卡片数组。 + +
+ 示例请求: + + ```json + { + "action": "getDecks", + "version": 6, + "params": { + "cards": [1502298036657, 1502298033753, 1502032366472] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": { + "Default": [1502032366472], + "Japanese::JLPT N3": [1502298036657, 1502298033753] + }, + "error": null + } + ``` +
+ +#### `createDeck` + +* 创建一个新的空卡组。不会覆盖同名的已存在卡组。 + +
+ 示例请求: + + ```json + { + "action": "createDeck", + "version": 6, + "params": { + "deck": "Japanese::Tokyo" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": 1519323742721, + "error": null + } + ``` +
+ +#### `changeDeck` + +* 将具有给定ID的卡片移动到不同的卡组,如果卡组尚不存在则创建它。 + +
+ 示例请求: + + ```json + { + "action": "changeDeck", + "version": 6, + "params": { + "cards": [1502098034045, 1502098034048, 1502298033753], + "deck": "Japanese::JLPT N3" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `deleteDecks` + +* 删除具有给定名称的卡组。 + 参数`cardsToo` *必须*被指定并设置为`true`。 + +
+ 示例请求: + + ```json + { + "action": "deleteDecks", + "version": 6, + "params": { + "decks": ["Japanese::JLPT N5", "Easy Spanish"], + "cardsToo": true + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `getDeckConfig` + +* 获取给定卡组的配置组对象。 + +
+ 示例请求: + + ```json + { + "action": "getDeckConfig", + "version": 6, + "params": { + "deck": "Default" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": { + "lapse": { + "leechFails": 8, + "delays": [10], + "minInt": 1, + "leechAction": 0, + "mult": 0 + }, + "dyn": false, + "autoplay": true, + "mod": 1502970872, + "id": 1, + "maxTaken": 60, + "new": { + "bury": true, + "order": 1, + "initialFactor": 2500, + "perDay": 20, + "delays": [1, 10], + "separate": true, + "ints": [1, 4, 7] + }, + "name": "Default", + "rev": { + "bury": true, + "ivlFct": 1, + "ease4": 1.3, + "maxIvl": 36500, + "perDay": 100, + "minSpace": 1, + "fuzz": 0.05 + }, + "timer": 0, + "replayq": true, + "usn": -1 + }, + "error": null + } + ``` +
+ +#### `saveDeckConfig` + +* 保存给定的配置组,成功时返回`true`,如果配置组的ID无效(例如不存在)则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "saveDeckConfig", + "version": 6, + "params": { + "config": { + "lapse": { + "leechFails": 8, + "delays": [10], + "minInt": 1, + "leechAction": 0, + "mult": 0 + }, + "dyn": false, + "autoplay": true, + "mod": 1502970872, + "id": 1, + "maxTaken": 60, + "new": { + "bury": true, + "order": 1, + "initialFactor": 2500, + "perDay": 20, + "delays": [1, 10], + "separate": true, + "ints": [1, 4, 7] + }, + "name": "Default", + "rev": { + "bury": true, + "ivlFct": 1, + "ease4": 1.3, + "maxIvl": 36500, + "perDay": 100, + "minSpace": 1, + "fuzz": 0.05 + }, + "timer": 0, + "replayq": true, + "usn": -1 + } + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `setDeckConfigId` + +* 将给定卡组的配置组更改为具有给定ID的配置组。成功时返回`true`,如果给定的配置组或任何给定的卡组不存在则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "setDeckConfigId", + "version": 6, + "params": { + "decks": ["Default"], + "configId": 1 + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `cloneDeckConfigId` + +* 使用给定的名称创建一个新的配置组,从具有给定ID的组克隆,或者如果未指定,则从默认组克隆。返回新配置组的ID,或者如果指定的要克隆的组不存在,则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "cloneDeckConfigId", + "version": 6, + "params": { + "name": "Copy of Default", + "cloneFrom": 1 + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": 1502972374573, + "error": null + } + ``` +
+ +#### `removeDeckConfigId` + +* 移除具有给定ID的配置组,如果成功则返回`true`,如果尝试移除默认配置组(ID = 1)或不存在的配置组则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "removeDeckConfigId", + "version": 6, + "params": { + "configId": 1502972374573 + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `getDeckStats` + +* 获取给定卡组的统计信息,如总卡片数和到期卡片数。 + +
+ 示例请求: + + ```json + { + "action": "getDeckStats", + "version": 6, + "params": { + "decks": ["Japanese::JLPT N5", "Easy Spanish"] + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": { + "1651445861967": { + "deck_id": 1651445861967, + "name": "Japanese::JLPT N5", + "new_count": 20, + "learn_count": 0, + "review_count": 0, + "total_in_deck": 1506 + }, + "1651445861960": { + "deck_id": 1651445861960, + "name": "Easy Spanish", + "new_count": 26, + "learn_count": 10, + "review_count": 5, + "total_in_deck": 852 + } + }, + "error": null + } + ``` +
+ +--- + +### 图形界面操作 + +#### `guiBrowse` + +* 调用*卡片浏览器*对话框并搜索给定查询。返回找到的卡片标识符数组。查询语法[在此处有文档](https://docs.ankiweb.net/searching.html)。 + + 可选地,可以提供`reorderCards`属性来重新排序*卡片浏览器*中显示的卡片。 + 这是一个包含`order`和`columnId`对象的数组。`order`可以是`ascending`或`descending`,而`columnId`可以是几个列标识符之一(如在[Anki源代码](https://github.com/ankitects/anki/blob/main/rslib/src/browser_table.rs)中记录的)。 + 指定的列需要在*卡片浏览器*中可见。 + +
+ 示例请求: + + ```json + { + "action": "guiBrowse", + "version": 6, + "params": { + "query": "deck:current", + "reorderCards": { + "order": "descending", + "columnId": "noteCrt" + } + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [1494723142483, 1494703460437, 1494703479525], + "error": null + } + ``` +
+ +#### `guiSelectCard` + +* 找到*卡片浏览器*对话框的打开实例,并根据卡片标识符选择一张卡片。 + 如果*卡片浏览器*是打开的,返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "guiSelectCard", + "version": 6, + "params": { + "card": 1494723142483 + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiSelectedNotes` + +* 找到*卡片浏览器*对话框的打开实例,并返回选中笔记的标识符数组。如果浏览器未打开,则返回空列表。 + +
+ 示例请求: + + ```json + { + "action": "guiSelectedNotes", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": [1494723142483, 1494703460437, 1494703479525], + "error": null + } + ``` +
+ +#### `guiAddCards` + +* 调用*添加卡片*对话框,使用给定的卡组和模型预设笔记,带有提供的字段值和标签。 + 多次调用会关闭旧窗口并_重新打开窗口_,使用新提供的值。 + + 可以通过`audio`、`video`和`picture`键将音频、视频和图片文件嵌入到字段中。 + 请参考`addNote`和`storeMediaFile`的文档,了解这些字段的说明。 + + 结果是如果用户选择确认*添加卡片*对话框,将添加的笔记的ID。 + +
+ 示例请求: + + ```json + { + "action": "guiAddCards", + "version": 6, + "params": { + "note": { + "deckName": "Default", + "modelName": "Cloze", + "fields": { + "Text": "The capital of Romania is {{c1::Bucharest}}", + "Extra": "Romania is a country in Europe" + }, + "tags": [ + "countries" + ], + "picture": [{ + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/13/EU-Romania.svg/285px-EU-Romania.svg.png", + "filename": "romania.png", + "fields": [ + "Extra" + ] + }] + } + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": 1496198395707, + "error": null + } + ``` +
+ +#### `guiEditNote` + +* 打开*编辑*对话框,显示对应于给定笔记ID的笔记。 + 该对话框类似于*编辑当前*对话框,但: + + * 有一个预览按钮,用于预览笔记的卡片 + * 有一个浏览按钮,用于打开浏览器并显示这些卡片 + * 有上一个/后退按钮,用于导航对话框的历史 + * 没有带有关闭按钮的栏 + +
+ 示例请求: + + ```json + { + "action": "guiEditNote", + "version": 6, + "params": { + "note": 1649198355435 + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `guiCurrentCard` + +* 返回有关当前卡片的信息,如果不在复习模式,则返回`null`。 + +
+ 示例请求: + + ```json + { + "action": "guiCurrentCard", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": { + "answer": "back content", + "question": "front content", + "deckName": "Default", + "modelName": "Basic", + "fieldOrder": 0, + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} + }, + "template": "Forward", + "cardId": 1498938915662, + "buttons": [1, 2, 3], + "nextReviews": ["<1m", "<10m", "4d"] + }, + "error": null + } + ``` +
+ +#### `guiStartCardTimer` + +* 启动或重置当前卡片的`timerStarted`值。这对于将开始时间推迟到通过API显示卡片时很有用,从而在调用`guiAnswerCard`时使记录的回答卡片所花费的时间更准确。 + +
+ 示例请求: + + ```json + { + "action": "guiStartCardTimer", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiShowQuestion` + +* 显示当前卡片的问题文本;如果处于复习模式则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "guiShowQuestion", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiShowAnswer` + +* 显示当前卡片的答案文本;如果处于复习模式则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "guiShowAnswer", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiAnswerCard` + +* 回答当前卡片;如果成功则返回`true`,否则返回`false`。注意,在Anki接受任何答案之前,必须先显示当前卡片的答案。 + +
+ 示例请求: + + ```json + { + "action": "guiAnswerCard", + "version": 6, + "params": { + "ease": 1 + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiUndo` + +* 撤销最后一个动作/卡片;如果成功则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "guiUndo", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiDeckOverview` + +* 为具有给定名称的卡组打开*卡组概览*对话框;如果成功则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "guiDeckOverview", + "version": 6, + "params": { + "name": "Default" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiDeckBrowser` + +* 打开*卡组浏览器*对话框。 + +
+ 示例请求: + + ```json + { + "action": "guiDeckBrowser", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `guiDeckReview` + +* 开始复习具有给定名称的卡组;如果成功则返回`true`,否则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "guiDeckReview", + "version": 6, + "params": { + "name": "Default" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiImportFile` + +* 调用*导入...(Ctrl+Shift+I)*对话框,并可选择提供文件路径。弹出对话框供用户审核导入。支持Anki支持的所有文件类型。如果未提供路径,则显示打开文件对话框。在Windows上的路径中必须使用正斜杠。仅支持Anki 2.1.52+。 + +
+ 示例请求: + + ```json + { + "action": "guiImportFile", + "version": 6, + "params": { + "path": "C:/Users/Desktop/cards.txt" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `guiExitAnki` + +* 安排一个请求来优雅地关闭Anki。此操作是异步的,因此它会立即返回,而不会等待Anki进程实际终止。 + +
+ 示例请求: + + ```json + { + "action": "guiExitAnki", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `guiCheckDatabase` + +* 请求进行数据库检查,但立即返回而不等待检查完成。因此,即使在数据库检查过程中检测到错误,此操作也将始终返回`true`。 + +
+ 示例请求: + + ```json + { + "action": "guiCheckDatabase", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +--- + +### 媒体操作 + +#### `storeMediaFile` + +* 将具有指定base64编码内容的文件存储在媒体文件夹中。或者,您可以指定绝对文件路径,或者从中下载文件的URL。如果提供了`data`、`path`和`url`中的多个,将首先使用`data`字段,然后是`path`,最后是`url`。为了防止Anki删除不被任何卡片使用的文件(例如配置文件),请在文件名前加下划线。这些文件仍然会同步到AnkiWeb。 + 默认情况下,将删除任何同名的现有文件。设置`deleteExisting`为false可以通过[让Anki为新文件提供非冲突的名称](https://github.com/ankitects/anki/blob/aeba725d3ea9628c73300648f748140db3fdd5ed/rslib/src/media/files.rs#L194)来防止这种情况。 + +
+ 示例请求(相对路径): + + ```json + { + "action": "storeMediaFile", + "version": 6, + "params": { + "filename": "_hello.txt", + "data": "SGVsbG8sIHdvcmxkIQ==" + } + } + ``` + + *`_hello.txt`的内容*: + + ``` + Hello world! + ``` +
+ +
+ 示例结果(相对路径): + + ```json + { + "result": "_hello.txt", + "error": null + } + ``` +
+ +
+ 示例请求(绝对路径): + + ```json + { + "action": "storeMediaFile", + "version": 6, + "params": { + "filename": "_hello.txt", + "path": "/path/to/file" + } + } + ``` +
+ +
+ 示例结果(绝对路径): + + ```json + { + "result": "_hello.txt", + "error": null + } + ``` +
+ +
+ 示例请求(url): + + ```json + { + "action": "storeMediaFile", + "version": 6, + "params": { + "filename": "_hello.txt", + "url": "https://url.to.file" + } + } + ``` +
+ +
+ 示例结果(url): + + ```json + { + "result": "_hello.txt", + "error": null + } + ``` +
+ +#### `retrieveMediaFile` + +* 检索指定文件的base64编码内容,如果文件不存在则返回`false`。 + +
+ 示例请求: + + ```json + { + "action": "retrieveMediaFile", + "version": 6, + "params": { + "filename": "_hello.txt" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": "SGVsbG8sIHdvcmxkIQ==", + "error": null + } + ``` +
+ +#### `getMediaFilesNames` + +* 获取与模式匹配的媒体文件名。默认返回所有名称。 + +
+ 示例请求: + + ```json + { + "action": "getMediaFilesNames", + "version": 6, + "params": { + "pattern": "_hell*.txt" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": ["_hello.txt"], + "error": null + } + ``` +
+ +#### `getMediaDirPath` + +* 获取当前打开的配置文件的`collection.media`文件夹的完整路径。 + +
+ 示例请求: + + ```json + { + "action": "getMediaDirPath", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": "/home/user/.local/share/Anki2/Main/collection.media", + "error": null + } + ``` +
+ +#### `deleteMediaFile` + +* 删除媒体文件夹中的指定文件。 + +
+ 示例请求: + + ```json + { + "action": "deleteMediaFile", + "version": 6, + "params": { + "filename": "_hello.txt" + } + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +--- + +### 杂项操作 + +#### `requestPermission` + +* 请求使用此插件公开的API的权限。此方法不需要API密钥,是唯一接受来自任何来源的请求的方法;其他方法只接受来自受信任来源的请求,这些来源列在插件配置的`webCorsOriginList`下。默认情况下,`localhost`是受信任的。 + + 从不受信任的来源调用此方法将在Anki中显示一个弹出窗口,询问用户是否允许您的来源使用API;来自受信任来源的调用将返回结果而不显示弹出窗口。 + 在拒绝许可时,用户还可以选择忽略来自该来源的进一步许可请求。这些来源最终会出现在`ignoreOriginList`中,可通过插件配置进行编辑。 + + 结果始终包含`permission`字段,该字段反过来包含字符串`granted`或`denied`,对应于您的来源是否受信任。如果您的来源受信任,还将返回字段`requireApiKey`(如果需要则为`true`)和`version`。 + + 这应该是您进行的第一个调用,以确保您的应用程序和Anki-Connect能够相互正确通信。新版本的Anki-Connect向后兼容;只要您使用在报告的Anki-Connect版本或更早版本中可用的操作,一切都应该正常工作。 + +
+ 示例请求: + + ```json + { + "action": "requestPermission", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": { + "permission": "granted", + "requireApiKey": false, + "version": 6 + }, + "error": null + } + ``` + + ```json + { + "result": { + "permission": "denied" + }, + "error": null + } + ``` +
+ +#### `version` + +* 获取此插件公开的API的版本。目前定义了版本`1`到`6`。 + +
+ 示例请求: + + ```json + { + "action": "version", + "version": 6 + } + ``` +
+ +
+ 示例结果: + + ```json + { + "result": 6, + "error": null + } + ``` +
+ + +#### `apiReflect` + +* 获取有关可用 AnkiConnect API 的信息。请求支持以下参数: + + * `scopes` - 要获取反射信息的作用域数组。 + 目前唯一支持的值是 `"actions"`。 + * `actions` - 可以是 `null` 或者 API 方法名称的数组。 + 如果值为 `null`,结果将列出所有可用的 API 动作。 + 如果值是字符串数组,结果将只包含该数组中存在的动作。 + + 结果将包含使用了哪些作用域及每个作用域的值。 + 例如,`"actions"` 作用域将包含一个 `"actions"` 属性,其中包含支持的动作名称列表。 + +
+ 请求示例: + + ```json + { + "action": "apiReflect", + "version": 6, + "params": { + "scopes": ["actions", "invalidType"], + "actions": ["apiReflect", "invalidMethod"] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": { + "scopes": ["actions"], + "actions": ["apiReflect"] + }, + "error": null + } + ``` +
+ +#### `sync` + +* 将本地 Anki 集合与 AnkiWeb 同步。 + +
+ 请求示例: + + ```json + { + "action": "sync", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `getProfiles` + +* 获取个人资料列表。 + +
+ 请求示例: + + ```json + { + "action": "getProfiles", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": ["User 1"], + "error": null + } + ``` +
+ +#### `getActiveProfile` + +* 获取当前活动个人资料。 + +
+ 请求示例: + + ```json + { + "action": "getActiveProfile", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": "User 1", + "error": null + } + ``` +
+ + +#### `loadProfile` + +* 选择请求中指定的个人资料。 + +
+ 请求示例: + + ```json + { + "action": "loadProfile", + "version": 6, + "params": { + "name": "user1" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `multi` + +* 在一个请求中执行多个操作,返回一个数组,其中包含每个操作的响应(按给定顺序)。 + +
+ 请求示例: + + ```json + { + "action": "multi", + "version": 6, + "params": { + "actions": [ + { + "action": "deckNames" + }, + { + "action": "deckNames", + "version": 6 + }, + { + "action": "invalidAction", + "params": {"useless": "param"} + }, + { + "action": "invalidAction", + "params": {"useless": "param"}, + "version": 6 + } + ] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": [ + ["Default"], + {"result": ["Default"], "error": null}, + {"result": null, "error": "unsupported action"}, + {"result": null, "error": "unsupported action"} + ], + "error": null + } + ``` +
+ +#### `exportPackage` + +* 将指定的牌组以 `.apkg` 格式导出。如果成功则返回 `true`,否则返回 `false`。可以指定可选属性 + `includeSched`(默认为 `false`)以包含卡片的调度数据。 + +
+ 请求示例: + + ```json + { + "action": "exportPackage", + "version": 6, + "params": { + "deck": "Default", + "path": "/data/Deck.apkg", + "includeSched": true + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `importPackage` + +* 将 `.apkg` 格式的文件导入集合中。如果成功则返回 `true`,否则返回 `false`。 + 注意,文件路径是相对于 Anki 的 collection.media 文件夹,而不是相对于客户端。 + +
+ 请求示例: + + ```json + { + "action": "importPackage", + "version": 6, + "params": { + "path": "/data/Deck.apkg" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `reloadCollection` + +* 告诉 Anki 从数据库重新加载所有数据。 + +
+ 请求示例: + + ```json + { + "action": "reloadCollection", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +--- + +### 模型(笔记类型)操作 + +#### `modelNames` + +* 获取当前用户的完整模型名称列表。 + +
+ 请求示例: + + ```json + { + "action": "modelNames", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": ["Basic", "Basic (and reversed card)"], + "error": null + } + ``` +
+ +#### `modelNamesAndIds` + +* 获取当前用户的完整模型名称及其对应的 ID 列表。 + +
+ 请求示例: + + ```json + { + "action": "modelNamesAndIds", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": { + "Basic": 1483883011648, + "Basic (and reversed card)": 1483883011644, + "Basic (optional reversed card)": 1483883011631, + "Cloze": 1483883011630 + }, + "error": null + } + ``` +
+ +#### `findModelsById` + +* 根据提供的模型 ID 从当前用户获取模型列表。 + +
+ 请求示例: + + ```json + { + "action": "findModelsById", + "version": 6, + "params": { + "modelIds": [1704387367119, 1704387398570] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": [ + { + "id": 1704387367119, + "name": "Basic", + "type": 0, + "mod": 1704387367, + "usn": -1, + "sortf": 0, + "did": null, + "tmpls": [ + { + "name": "Card 1", + "ord": 0, + "qfmt": "{{Front}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": 9176047152973362695 + } + ], + "flds": [ + { + "name": "Front", + "ord": 0, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": 2453723143453745216, + "tag": null, + "preventDeletion": false + }, + { + "name": "Back", + "ord": 1, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": -4853200230425436781, + "tag": null, + "preventDeletion": false + } + ], + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexPost": "\\end{document}", + "latexsvg": false, + "req": [ + [ + 0, + "any", + [ + 0 + ] + ] + ], + "originalStockKind": 1 + }, + { + "id": 1704387398570, + "name": "Basic (and reversed card)", + "type": 0, + "mod": 1704387398, + "usn": -1, + "sortf": 0, + "did": null, + "tmpls": [ + { + "name": "Card 1", + "ord": 0, + "qfmt": "{{Front}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": 1689886528158874152 + }, + { + "name": "Card 2", + "ord": 1, + "qfmt": "{{Back}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Front}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": -7839609225644824587 + } + ], + "flds": [ + { + "name": "Front", + "ord": 0, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": -7787837672455357996, + "tag": null, + "preventDeletion": false + }, + { + "name": "Back", + "ord": 1, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": 6364828289839985081, + "tag": null, + "preventDeletion": false + } + ], + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexPost": "\\end{document}", + "latexsvg": false, + "req": [ + [ + 0, + "any", + [ + 0 + ] + ], + [ + 1, + "any", + [ + 1 + ] + ] + ], + "originalStockKind": 1 + } + ], + "error": null + } + ``` +
+ + +#### `findModelsByName` + +* 根据提供的模型名称从当前用户获取模型列表。 + +
+ 请求示例: + + ```json + { + "action": "findModelsByName", + "version": 6, + "params": { + "modelNames": ["Basic", "Basic (and reversed card)"] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": [ + { + "id": 1704387367119, + "name": "Basic", + "type": 0, + "mod": 1704387367, + "usn": -1, + "sortf": 0, + "did": null, + "tmpls": [ + { + "name": "Card 1", + "ord": 0, + "qfmt": "{{Front}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": 9176047152973362695 + } + ], + "flds": [ + { + "name": "Front", + "ord": 0, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": 2453723143453745216, + "tag": null, + "preventDeletion": false + }, + { + "name": "Back", + "ord": 1, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": -4853200230425436781, + "tag": null, + "preventDeletion": false + } + ], + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexPost": "\\end{document}", + "latexsvg": false, + "req": [ + [ + 0, + "any", + [ + 0 + ] + ] + ], + "originalStockKind": 1 + }, + { + "id": 1704387398570, + "name": "Basic (and reversed card)", + "type": 0, + "mod": 1704387398, + "usn": -1, + "sortf": 0, + "did": null, + "tmpls": [ + { + "name": "Card 1", + "ord": 0, + "qfmt": "{{Front}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": 1689886528158874152 + }, + { + "name": "Card 2", + "ord": 1, + "qfmt": "{{Back}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Front}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": -7839609225644824587 + } + ], + "flds": [ + { + "name": "Front", + "ord": 0, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": -7787837672455357996, + "tag": null, + "preventDeletion": false + }, + { + "name": "Back", + "ord": 1, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": 6364828289839985081, + "tag": null, + "preventDeletion": false + } + ], + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexPost": "\\end{document}", + "latexsvg": false, + "req": [ + [ + 0, + "any", + [ + 0 + ] + ], + [ + 1, + "any", + [ + 1 + ] + ] + ], + "originalStockKind": 1 + } + ], + "error": null + } + ``` +
+ +#### `modelFieldNames` + +* 获取提供的模型名称的完整字段名称列表。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldNames", + "version": 6, + "params": { + "modelName": "Basic" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": ["Front", "Back"], + "error": null + } + ``` +
+ +#### `modelFieldDescriptions` + +* 获取提供的模型名称的完整字段描述列表(当字段为空时在 GUI 编辑器中显示的文本)。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldDescriptions", + "version": 6, + "params": { + "modelName": "Basic" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": ["", ""], + "error": null + } + ``` +
+ +#### `modelFieldFonts` + +* 获取完整的字体列表及其字体大小。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldFonts", + "version": 6, + "params": { + "modelName": "Basic" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": { + "Front": { + "font": "Arial", + "size": 20 + }, + "Back": { + "font": "Arial", + "size": 20 + } + }, + "error": null + } + ``` +
+ +#### `modelFieldsOnTemplates` + +* 返回一个对象,指示给定模型名称的每个卡片模板的问题侧和答案侧的字段。 + 问题侧在每个数组中首先给出。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldsOnTemplates", + "version": 6, + "params": { + "modelName": "Basic (and reversed card)" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": { + "Card 1": [["Front"], ["Back"]], + "Card 2": [["Back"], ["Front"]] + }, + "error": null + } + ``` +
+ +#### `createModel` + +* 创建一个新模型以在 Anki 中使用。用户必须提供 `modelName`、`inOrderFields` 和 `cardTemplates` 来 + 在模型中使用。有可选字段 `css` 和 `isCloze`。如果未指定,`css` 将使用默认的 Anki CSS,`isCloze` 将等于 `false`。如果 `isCloze` 为 `true`,则模型将创建为填空题型。 + + 可以为 `cardTemplates` 的每个条目提供可选的 `Name` 字段。默认情况下, + 卡片名称将是 `Card 1`, `Card 2` 等。 + +
+ 请求示例: + + ```json + { + "action": "createModel", + "version": 6, + "params": { + "modelName": "newModelName", + "inOrderFields": ["Field1", "Field2", "Field3"], + "css": "Optional CSS with default to builtin css", + "isCloze": false, + "cardTemplates": [ + { + "Name": "My Card 1", + "Front": "Front html {{Field1}}", + "Back": "Back html {{Field2}}" + } + ] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result":{ + "sortf":0, + "did":1, + "latexPre":"\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexPost":"\\end{document}", + "mod":1551462107, + "usn":-1, + "vers":[ + + ], + "type":0, + "css":".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "name":"TestApiModel", + "flds":[ + { + "name":"Field1", + "ord":0, + "sticky":false, + "rtl":false, + "font":"Arial", + "size":20, + "media":[ + + ] + }, + { + "name":"Field2", + "ord":1, + "sticky":false, + "rtl":false, + "font":"Arial", + "size":20, + "media":[ + + ] + } + ], + "tmpls":[ + { + "name":"My Card 1", + "ord":0, + "qfmt":"", + "afmt":"This is the back of the card {{Field2}}", + "did":null, + "bqfmt":"", + "bafmt":"" + } + ], + "tags":[ + + ], + "id":1551462107104, + "req":[ + [ + 0, + "none", + [ + + ] + ] + ] + }, + "error":null + } + ``` +
+ +#### `modelTemplates` + +* 返回一个对象,指示与提供的模型名称相连接的每个卡片的模板内容。 + +
+ 请求示例: + + ```json + { + "action": "modelTemplates", + "version": 6, + "params": { + "modelName": "Basic (and reversed card)" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": { + "Card 1": { + "Front": "{{Front}}", + "Back": "{{FrontSide}}\n\n
\n\n{{Back}}" + }, + "Card 2": { + "Front": "{{Back}}", + "Back": "{{FrontSide}}\n\n
\n\n{{Front}}" + } + }, + "error": null + } + ``` +
+ +#### `modelStyling` + +* 获取按名称提供的模型的 CSS 样式。 + +
+ 请求示例: + + ```json + { + "action": "modelStyling", + "version": 6, + "params": { + "modelName": "Basic (and reversed card)" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": { + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n" + }, + "error": null + } + ``` +
+ +#### `updateModelTemplates` + +* 修改现有模型的模板(通过名称指定)。只有指定的卡片和指定的侧面会被修改。 + 如果请求中不包括现有卡片或侧面,它将保持不变。 + +
+ 请求示例: + + ```json + { + "action": "updateModelTemplates", + "version": 6, + "params": { + "model": { + "name": "Custom", + "templates": { + "Card 1": { + "Front": "{{Question}}?", + "Back": "{{Answer}}!" + } + } + } + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `updateModelStyling` + +* 修改现有模型的 CSS 样式(通过名称指定)。 + +
+ 请求示例: + + ```json + { + "action": "updateModelStyling", + "version": 6, + "params": { + "model": { + "name": "Custom", + "css": "p { color: blue; }" + } + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `findAndReplaceInModels` + +* 在现有模型中查找并替换字符串(通过模型名称)。通过设置为 true/false 自定义在前面、后面或 CSS 中替换。 + +
+ 请求示例: + + ```json + { + "action": "findAndReplaceInModels", + "version": 6, + "params": { + "model": { + "modelName": "", + "findText": "text_to_replace", + "replaceText": "replace_with_text", + "front": true, + "back": true, + "css": true + } + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": 1, + "error": null + } + ``` +
+ +#### `modelTemplateRename` + +* 重命名现有模型中的模板。 + +
+ 请求示例: + + ```json + { + "action": "modelTemplateRename", + "version": 6, + "params": { + "modelName": "Basic", + "oldTemplateName": "Card 1", + "newTemplateName": "Card 1 renamed" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelTemplateReposition` + +* 重新定位现有模型中的模板。 + + `index` 的值从 0 开始。例如,索引 `0` 将模板放在第一个位置,索引 `2` 将模板放在第三个位置。 + +
+ 请求示例: + + ```json + { + "action": "modelTemplateReposition", + "version": 6, + "params": { + "modelName": "Basic", + "templateName": "Card 1", + "index": 1 + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelTemplateAdd` + +* 通过名称向现有模型添加模板。如果要更新现有模板,请使用 `updateModelTemplates`。 + +
+ 请求示例: + + ```json + { + "action": "modelTemplateAdd", + "version": 6, + "params": { + "modelName": "Basic", + "template": { + "Name": "Card 3", + "Front": "Front html {{Field1}}", + "Back": "Back html {{Field2}}" + } + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelTemplateRemove` + +* 从现有模型中移除模板。 + +
+ 请求示例: + + ```json + { + "action": "modelTemplateRemove", + "version": 6, + "params": { + "modelName": "Basic", + "templateName": "Card 1" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldRename` + +* 重命名给定模型的字段名称。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldRename", + "version": 6, + "params": { + "modelName": "Basic", + "oldFieldName": "Front", + "newFieldName": "FrontRenamed" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldReposition` + +* 重新定位给定模型的字段列表中的字段。 + + `index` 的值从 0 开始。例如,索引 `0` 将字段放在第一个位置,索引 `2` 将字段放在第三个位置。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldReposition", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "Back", + "index": 0 + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldAdd` + +* 在给定模型中创建新字段。 + + 可以选择提供 `index` 值,其工作方式与 `modelFieldReposition` 中的索引完全相同。默认情况下,字段将添加到字段列表的末尾。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldAdd", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "NewField", + "index": 0 + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldRemove` + +* 删除给定模型中的字段。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldRemove", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "Front" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldSetFont` + +* 设置给定模型中字段的字体。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldSetFont", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "Front", + "font": "Courier" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldSetFontSize` + +* 设置给定模型中字段的字体大小。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldSetFontSize", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "Front", + "fontSize": 10 + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldSetDescription` + +* 设置给定模型中字段的描述(当字段为空时在 GUI 编辑器中看到的文本)。 + + 旧版本的 Anki(2.1.49 及以下)没有字段描述。在这种情况下,将返回 `false`。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldSetDescription", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "Front", + "description": "example field description" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +--- + +### 笔记操作 + +#### `addNote` + +* 使用给定的牌组和模型创建一个笔记,包含提供的字段值和标签。成功时返回创建的笔记的标识符, + 失败时返回 `null`。 + + Anki-Connect 可以下载音频、视频和图片文件,并将它们嵌入到新创建的笔记中。相应的 `audio`、`video` 和 `picture` 笔记成员是 + 可选的,可以省略。如果选择包含它们中的任何一个,它们应该包含一个对象或一个对象数组, + 带有必填的 `filename` 字段和 `data`、`path` 或 `url` 之一。有关这些字段的解释,请参阅 `storeMediaFile` 的文档。 + 可以选择提供 `skipHash` 字段,以跳过包含与提供的值匹配的 MD5 哈希的文件。 + 这对于避免保存错误页面和存根文件很有用。 + `fields` 成员是一个字段列表,这些字段应该在卡片显示在 Anki 中时播放音频或视频,或显示图片。 + `options` 组中的 `allowDuplicate` 成员可以设置为 true 以启用添加重复卡片。 + 通常不能添加重复卡片,会触发异常。 + + `options` 中的 `duplicateScope` 成员可用于指定检查重复项的范围。 + 值为 `"deck"` 将只检查目标牌组中的重复项;任何其他值将检查整个集合。 + + `duplicateScopeOptions` 对象可用于指定一些附加设置: + + * `duplicateScopeOptions.deckName` 将指定用于检查重复项的牌组。如果未定义或为 `null`,将使用目标牌组。 + * `duplicateScopeOptions.checkChildren` 将更改是否在子牌组中检查重复卡片。默认值为 `false`。 + * `duplicateScopeOptions.checkAllModels` 指定是否在所有笔记类型中执行重复检查。默认值为 `false`。 + +
+ 请求示例: + + ```json + { + "action": "addNote", + "version": 6, + "params": { + "note": { + "deckName": "Default", + "modelName": "Basic", + "fields": { + "Front": "front content", + "Back": "back content" + }, + "options": { + "allowDuplicate": false, + "duplicateScope": "deck", + "duplicateScopeOptions": { + "deckName": "Default", + "checkChildren": false, + "checkAllModels": false + } + }, + "tags": [ + "yomichan" + ], + "audio": [{ + "url": "https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?kanji=猫&kana=ねこ", + "filename": "yomichan_ねこ_猫.mp3", + "skipHash": "7e2c2f954ef6051373ba916f000168dc", + "fields": [ + "Front" + ] + }], + "video": [{ + "url": "https://cdn.videvo.net/videvo_files/video/free/2015-06/small_watermarked/Contador_Glam_preview.mp4", + "filename": "countdown.mp4", + "skipHash": "4117e8aab0d37534d9c8eac362388bbe", + "fields": [ + "Back" + ] + }], + "picture": [{ + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c7/A_black_cat_named_Tilly.jpg/220px-A_black_cat_named_Tilly.jpg", + "filename": "black_cat.jpg", + "skipHash": "8d6e4646dfae812bf39651b59d7429ce", + "fields": [ + "Back" + ] + }] + } + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": 1496198395707, + "error": null + } + ``` +
+ +#### `addNotes` + +* 使用给定的牌组和模型创建多个笔记,包含提供的字段值和标签。返回一个数组, + 包含创建的笔记的标识符。如有任何错误,将收集并返回所有错误。 +* 有关 `notes` 数组中对象的解释,请参阅 `addNote` 的文档。 + +
+ 请求示例: + + ```json + { + "action":"addNotes", + "version":6, + "params":{ + "notes":[ + { + "deckName":"College::PluginDev", + "modelName":"non_existent_model", + "fields":{ + "Front":"front", + "Back":"bak" + } + }, + { + "deckName":"College::PluginDev", + "modelName":"Basic", + "fields":{ + "Front":"front", + "Back":"bak" + } + } + ] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result":null, + "error":"['model was not found: non_existent_model']" + } + ``` +
+ +#### `canAddNotes` + +* 接受一个对象数组,这些对象定义候选笔记的参数(参见 `addNote`),并返回一个布尔值数组, + 指示相应索引处的参数是否可用于创建新笔记。 + +
+ 请求示例: + + ```json + { + "action": "canAddNotes", + "version": 6, + "params": { + "notes": [ + { + "deckName": "Default", + "modelName": "Basic", + "fields": { + "Front": "front content", + "Back": "back content" + }, + "tags": [ + "yomichan" + ] + } + ] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": [true], + "error": null + } + ``` +
+ +#### `canAddNotesWithErrorDetail` + +* 接受一个对象数组,这些对象定义候选笔记的参数(参见 `addNote`),并返回一个对象数组, + 包含 `canAdd` 和 `error` 字段。 + + * `canAdd` 指示相应索引处的参数是否可用于创建新笔记。 + * `error` 包含无法添加笔记的原因说明。 + +
+ 请求示例: + + ```json + { + "action": "canAddNotesWithErrorDetail", + "version": 6, + "params": { + "notes": [ + { + "deckName": "Default", + "modelName": "Basic", + "fields": { + "Front": "front content", + "Back": "back content" + }, + "tags": [ + "yomichan" + ] + }, + { + "deckName": "Default", + "modelName": "Basic", + "fields": { + "Front": "front content 2", + "Back": "back content 2" + }, + "tags": [ + "yomichan" + ] + } + ] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": [ + { + "canAdd": false, + "error": "cannot create note because it is a duplicate" + }, + { + "canAdd": true + } + ], + "error": null + } + ``` +
+ +#### `updateNoteFields` + +* 修改现有笔记的字段。您还可以包含音频、视频或图片文件,这些文件将添加到笔记中,并带有 + 可选的 `audio`、`video` 或 `picture` 属性。有关 `audio`、`video` 或 `picture` 数组中对象的解释,请参阅 `addNote` 的文档。 + + > **警告**: + > 您不能在 Anki 浏览器中查看您正在更新的笔记,否则 + > 字段将不会更新。有关更多详细信息,请参阅[此问题](https://github.com/FooSoft/anki-connect/issues/82)。 + +
+ 请求示例: + + ```json + { + "action": "updateNoteFields", + "version": 6, + "params": { + "note": { + "id": 1514547547030, + "fields": { + "Front": "new front content", + "Back": "new back content" + }, + "audio": [{ + "url": "https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?kanji=猫&kana=ねこ", + "filename": "yomichan_ねこ_猫.mp3", + "skipHash": "7e2c2f954ef6051373ba916f000168dc", + "fields": [ + "Front" + ] + }] + } + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `updateNote` + +* 修改现有笔记的字段和/或标签。 + 换句话说,结合了 `updateNoteFields` 和 `updateNoteTags` 的功能。 + 请参阅它们的文档以了解所有属性。 + + 可以省略 `fields` 或 `tags` 属性,而不影响另一个。 + 因此有效的 `updateNoteFields` 请求也适用于 `updateNote`。 + 笔记必须具有 `fields` 属性才能更新可选的音频、视频或图片对象。 + + 如果既没有提供 `fields` 也没有提供 `tags`,则该方法将失败。 + 首先更新字段,如果更新标签失败,则不会回滚字段更新。 + 如果更新字段失败,则不会更新标签。 + + > **警告** + > 您不能在 Anki 浏览器中查看您正在更新的笔记,否则 + > 字段将不会更新。有关更多详细信息,请参阅[此问题](https://github.com/FooSoft/anki-connect/issues/82)。 + +
+ 请求示例: + + ```json + { + "action": "updateNote", + "version": 6, + "params": { + "note": { + "id": 1514547547030, + "fields": { + "Front": "new front content", + "Back": "new back content" + }, + "tags": ["new", "tags"] + } + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `updateNoteModel` + +* 更新现有笔记的模型、字段和标签。 + 这允许您更改笔记的模型,用新内容更新其字段,并设置新标签。 + +
+ 请求示例: + + ```json + { + "action": "updateNoteModel", + "version": 6, + "params": { + "note": { + "id": 1514547547030, + "modelName": "NewModel", + "fields": { + "NewField1": "new field 1", + "NewField2": "new field 2", + "NewField3": "new field 3" + }, + "tags": ["new", "updated", "tags"] + } + } + } + ``` + +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` + +
+ +#### `updateNoteTags` + +* 通过笔记 ID 设置笔记的标签。旧标签将被移除。 + +
+ 请求示例: + + ```json + { + "action": "updateNoteTags", + "version": 6, + "params": { + "note": 1483959289817, + "tags": ["european-languages"] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `getNoteTags` + +* 通过笔记 ID 获取笔记的标签。 + +
+ 请求示例: + + ```json + { + "action": "getNoteTags", + "version": 6, + "params": { + "note": 1483959289817 + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": ["european-languages"], + "error": null + } + ``` +
+ +#### `addTags` + +* 通过笔记 ID 向笔记添加标签。 + +
+ 请求示例: + + ```json + { + "action": "addTags", + "version": 6, + "params": { + "notes": [1483959289817, 1483959291695], + "tags": "european-languages" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `removeTags` + +* 通过笔记 ID 从笔记中移除标签。 + +
+ 请求示例: + + ```json + { + "action": "removeTags", + "version": 6, + "params": { + "notes": [1483959289817, 1483959291695], + "tags": "european-languages" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `getTags` + +* 获取当前用户的标签完整列表。 + +
+ 请求示例: + + ```json + { + "action": "getTags", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": ["european-languages", "idioms"], + "error": null + } + ``` +
+ +#### `clearUnusedTags` + +* 清除当前用户笔记中所有未使用的标签。 + +
+ 请求示例: + + ```json + { + "action": "clearUnusedTags", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `replaceTags` + +* 通过笔记 ID 替换笔记中的标签。 + +
+ 请求示例: + + ```json + { + "action": "replaceTags", + "version": 6, + "params": { + "notes": [1483959289817, 1483959291695], + "tag_to_replace": "european-languages", + "replace_with_tag": "french-languages" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `replaceTagsInAllNotes` + +* 替换当前用户所有笔记中的标签。 + +
+ 请求示例: + + ```json + { + "action": "replaceTagsInAllNotes", + "version": 6, + "params": { + "tag_to_replace": "european-languages", + "replace_with_tag": "french-languages" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `findNotes` + +* 返回给定查询的笔记 ID 数组。查询语法[在此处有文档](https://docs.ankiweb.net/searching.html)。 + +
+ 请求示例: + + ```json + { + "action": "findNotes", + "version": 6, + "params": { + "query": "deck:current" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": [1483959289817, 1483959291695], + "error": null + } + ``` +
+ +#### `notesInfo` + +* 返回一个对象列表,每个笔记 ID 包含笔记字段、标签、笔记类型、修改时间、属于该笔记的卡片以及创建笔记的个人资料。 + +
+ 请求示例(笔记 id): + + ```json + { + "action": "notesInfo", + "version": 6, + "params": { + "notes": [1502298033753] + } + } + ``` +
+ +
+ 请求示例(查询): + + ```json + { + "action": "notesInfo", + "version": 6, + "params": { + "query": "deck:current" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": [ + { + "noteId":1502298033753, + "profile": "User_1", + "modelName": "Basic", + "tags":["tag","another_tag"], + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} + }, + "mod": 1718377864, + "cards": [1498938915662] + } + ], + "error": null + } + ``` +
+s +#### `notesModTime` + +* 返回一个对象列表,每个笔记 ID 包含修改时间。 + +
+ 请求示例: +#### `apiReflect` + +* 获取有关可用 AnkiConnect API 的信息。请求支持以下参数: + + * `scopes` - 要获取反射信息的作用域数组。 + 目前唯一支持的值是 `"actions"`。 + * `actions` - 可以是 `null` 或者 API 方法名称的数组。 + 如果值为 `null`,结果将列出所有可用的 API 动作。 + 如果值是字符串数组,结果将只包含该数组中存在的动作。 + + 结果将包含使用了哪些作用域及每个作用域的值。 + 例如,`"actions"` 作用域将包含一个 `"actions"` 属性,其中包含支持的动作名称列表。 + +
+ 请求示例: + + ```json + { + "action": "apiReflect", + "version": 6, + "params": { + "scopes": ["actions", "invalidType"], + "actions": ["apiReflect", "invalidMethod"] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": { + "scopes": ["actions"], + "actions": ["apiReflect"] + }, + "error": null + } + ``` +
+ +#### `sync` + +* 将本地 Anki 集合与 AnkiWeb 同步。 + +
+ 请求示例: + + ```json + { + "action": "sync", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `getProfiles` + +* 获取个人资料列表。 + +
+ 请求示例: + + ```json + { + "action": "getProfiles", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": ["User 1"], + "error": null + } + ``` +
+ +#### `getActiveProfile` + +* 获取当前活动个人资料。 + +
+ 请求示例: + + ```json + { + "action": "getActiveProfile", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": "User 1", + "error": null + } + ``` +
+ + +#### `loadProfile` + +* 选择请求中指定的个人资料。 + +
+ 请求示例: + + ```json + { + "action": "loadProfile", + "version": 6, + "params": { + "name": "user1" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `multi` + +* 在一个请求中执行多个操作,返回一个数组,其中包含每个操作的响应(按给定顺序)。 + +
+ 请求示例: + + ```json + { + "action": "multi", + "version": 6, + "params": { + "actions": [ + { + "action": "deckNames" + }, + { + "action": "deckNames", + "version": 6 + }, + { + "action": "invalidAction", + "params": {"useless": "param"} + }, + { + "action": "invalidAction", + "params": {"useless": "param"}, + "version": 6 + } + ] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": [ + ["Default"], + {"result": ["Default"], "error": null}, + {"result": null, "error": "unsupported action"}, + {"result": null, "error": "unsupported action"} + ], + "error": null + } + ``` +
+ +#### `exportPackage` + +* 将指定的牌组以 `.apkg` 格式导出。如果成功则返回 `true`,否则返回 `false`。可以指定可选属性 + `includeSched`(默认为 `false`)以包含卡片的调度数据。 + +
+ 请求示例: + + ```json + { + "action": "exportPackage", + "version": 6, + "params": { + "deck": "Default", + "path": "/data/Deck.apkg", + "includeSched": true + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `importPackage` + +* 将 `.apkg` 格式的文件导入集合中。如果成功则返回 `true`,否则返回 `false`。 + 注意,文件路径是相对于 Anki 的 collection.media 文件夹,而不是相对于客户端。 + +
+ 请求示例: + + ```json + { + "action": "importPackage", + "version": 6, + "params": { + "path": "/data/Deck.apkg" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `reloadCollection` + +* 告诉 Anki 从数据库重新加载所有数据。 + +
+ 请求示例: + + ```json + { + "action": "reloadCollection", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +--- + +### 模型(笔记类型)操作 + +#### `modelNames` + +* 获取当前用户的完整模型名称列表。 + +
+ 请求示例: + + ```json + { + "action": "modelNames", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": ["Basic", "Basic (and reversed card)"], + "error": null + } + ``` +
+ +#### `modelNamesAndIds` + +* 获取当前用户的完整模型名称及其对应的 ID 列表。 + +
+ 请求示例: + + ```json + { + "action": "modelNamesAndIds", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": { + "Basic": 1483883011648, + "Basic (and reversed card)": 1483883011644, + "Basic (optional reversed card)": 1483883011631, + "Cloze": 1483883011630 + }, + "error": null + } + ``` +
+ +#### `findModelsById` + +* 根据提供的模型 ID 从当前用户获取模型列表。 + +
+ 请求示例: + + ```json + { + "action": "findModelsById", + "version": 6, + "params": { + "modelIds": [1704387367119, 1704387398570] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": [ + { + "id": 1704387367119, + "name": "Basic", + "type": 0, + "mod": 1704387367, + "usn": -1, + "sortf": 0, + "did": null, + "tmpls": [ + { + "name": "Card 1", + "ord": 0, + "qfmt": "{{Front}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": 9176047152973362695 + } + ], + "flds": [ + { + "name": "Front", + "ord": 0, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": 2453723143453745216, + "tag": null, + "preventDeletion": false + }, + { + "name": "Back", + "ord": 1, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": -4853200230425436781, + "tag": null, + "preventDeletion": false + } + ], + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexPost": "\\end{document}", + "latexsvg": false, + "req": [ + [ + 0, + "any", + [ + 0 + ] + ] + ], + "originalStockKind": 1 + }, + { + "id": 1704387398570, + "name": "Basic (and reversed card)", + "type": 0, + "mod": 1704387398, + "usn": -1, + "sortf": 0, + "did": null, + "tmpls": [ + { + "name": "Card 1", + "ord": 0, + "qfmt": "{{Front}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": 1689886528158874152 + }, + { + "name": "Card 2", + "ord": 1, + "qfmt": "{{Back}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Front}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": -7839609225644824587 + } + ], + "flds": [ + { + "name": "Front", + "ord": 0, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": -7787837672455357996, + "tag": null, + "preventDeletion": false + }, + { + "name": "Back", + "ord": 1, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": 6364828289839985081, + "tag": null, + "preventDeletion": false + } + ], + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexPost": "\\end{document}", + "latexsvg": false, + "req": [ + [ + 0, + "any", + [ + 0 + ] + ], + [ + 1, + "any", + [ + 1 + ] + ] + ], + "originalStockKind": 1 + } + ], + "error": null + } + ``` +
+ + +#### `findModelsByName` + +* 根据提供的模型名称从当前用户获取模型列表。 + +
+ 请求示例: + + ```json + { + "action": "findModelsByName", + "version": 6, + "params": { + "modelNames": ["Basic", "Basic (and reversed card)"] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": [ + { + "id": 1704387367119, + "name": "Basic", + "type": 0, + "mod": 1704387367, + "usn": -1, + "sortf": 0, + "did": null, + "tmpls": [ + { + "name": "Card 1", + "ord": 0, + "qfmt": "{{Front}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": 9176047152973362695 + } + ], + "flds": [ + { + "name": "Front", + "ord": 0, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": 2453723143453745216, + "tag": null, + "preventDeletion": false + }, + { + "name": "Back", + "ord": 1, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": -4853200230425436781, + "tag": null, + "preventDeletion": false + } + ], + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexPost": "\\end{document}", + "latexsvg": false, + "req": [ + [ + 0, + "any", + [ + 0 + ] + ] + ], + "originalStockKind": 1 + }, + { + "id": 1704387398570, + "name": "Basic (and reversed card)", + "type": 0, + "mod": 1704387398, + "usn": -1, + "sortf": 0, + "did": null, + "tmpls": [ + { + "name": "Card 1", + "ord": 0, + "qfmt": "{{Front}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": 1689886528158874152 + }, + { + "name": "Card 2", + "ord": 1, + "qfmt": "{{Back}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Front}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": -7839609225644824587 + } + ], + "flds": [ + { + "name": "Front", + "ord": 0, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": -7787837672455357996, + "tag": null, + "preventDeletion": false + }, + { + "name": "Back", + "ord": 1, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": 6364828289839985081, + "tag": null, + "preventDeletion": false + } + ], + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexPost": "\\end{document}", + "latexsvg": false, + "req": [ + [ + 0, + "any", + [ + 0 + ] + ], + [ + 1, + "any", + [ + 1 + ] + ] + ], + "originalStockKind": 1 + } + ], + "error": null + } + ``` +
+ +#### `modelFieldNames` + +* 获取提供的模型名称的完整字段名称列表。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldNames", + "version": 6, + "params": { + "modelName": "Basic" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": ["Front", "Back"], + "error": null + } + ``` +
+ +#### `modelFieldDescriptions` + +* 获取提供的模型名称的完整字段描述列表(当字段为空时在 GUI 编辑器中显示的文本)。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldDescriptions", + "version": 6, + "params": { + "modelName": "Basic" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": ["", ""], + "error": null + } + ``` +
+ +#### `modelFieldFonts` + +* 获取完整的字体列表及其字体大小。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldFonts", + "version": 6, + "params": { + "modelName": "Basic" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": { + "Front": { + "font": "Arial", + "size": 20 + }, + "Back": { + "font": "Arial", + "size": 20 + } + }, + "error": null + } + ``` +
+ +#### `modelFieldsOnTemplates` + +* 返回一个对象,指示给定模型名称的每个卡片模板的问题侧和答案侧的字段。 + 问题侧在每个数组中首先给出。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldsOnTemplates", + "version": 6, + "params": { + "modelName": "Basic (and reversed card)" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": { + "Card 1": [["Front"], ["Back"]], + "Card 2": [["Back"], ["Front"]] + }, + "error": null + } + ``` +
+ +#### `createModel` + +* 创建一个新模型以在 Anki 中使用。用户必须提供 `modelName`、`inOrderFields` 和 `cardTemplates` 来 + 在模型中使用。有可选字段 `css` 和 `isCloze`。如果未指定,`css` 将使用默认的 Anki CSS,`isCloze` 将等于 `false`。如果 `isCloze` 为 `true`,则模型将创建为填空题型。 + + 可以为 `cardTemplates` 的每个条目提供可选的 `Name` 字段。默认情况下, + 卡片名称将是 `Card 1`, `Card 2` 等。 + +
+ 请求示例: + + ```json + { + "action": "createModel", + "version": 6, + "params": { + "modelName": "newModelName", + "inOrderFields": ["Field1", "Field2", "Field3"], + "css": "Optional CSS with default to builtin css", + "isCloze": false, + "cardTemplates": [ + { + "Name": "My Card 1", + "Front": "Front html {{Field1}}", + "Back": "Back html {{Field2}}" + } + ] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result":{ + "sortf":0, + "did":1, + "latexPre":"\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexPost":"\\end{document}", + "mod":1551462107, + "usn":-1, + "vers":[ + + ], + "type":0, + "css":".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "name":"TestApiModel", + "flds":[ + { + "name":"Field1", + "ord":0, + "sticky":false, + "rtl":false, + "font":"Arial", + "size":20, + "media":[ + + ] + }, + { + "name":"Field2", + "ord":1, + "sticky":false, + "rtl":false, + "font":"Arial", + "size":20, + "media":[ + + ] + } + ], + "tmpls":[ + { + "name":"My Card 1", + "ord":0, + "qfmt":"", + "afmt":"This is the back of the card {{Field2}}", + "did":null, + "bqfmt":"", + "bafmt":"" + } + ], + "tags":[ + + ], + "id":1551462107104, + "req":[ + [ + 0, + "none", + [ + + ] + ] + ] + }, + "error":null + } + ``` +
+ +#### `modelTemplates` + +* 返回一个对象,指示与提供的模型名称相连接的每个卡片的模板内容。 + +
+ 请求示例: + + ```json + { + "action": "modelTemplates", + "version": 6, + "params": { + "modelName": "Basic (and reversed card)" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": { + "Card 1": { + "Front": "{{Front}}", + "Back": "{{FrontSide}}\n\n
\n\n{{Back}}" + }, + "Card 2": { + "Front": "{{Back}}", + "Back": "{{FrontSide}}\n\n
\n\n{{Front}}" + } + }, + "error": null + } + ``` +
+ +#### `modelStyling` + +* 获取按名称提供的模型的 CSS 样式。 + +
+ 请求示例: + + ```json + { + "action": "modelStyling", + "version": 6, + "params": { + "modelName": "Basic (and reversed card)" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": { + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n" + }, + "error": null + } + ``` +
+ +#### `updateModelTemplates` + +* 修改现有模型的模板(通过名称指定)。只有指定的卡片和指定的侧面会被修改。 + 如果请求中不包括现有卡片或侧面,它将保持不变。 + +
+ 请求示例: + + ```json + { + "action": "updateModelTemplates", + "version": 6, + "params": { + "model": { + "name": "Custom", + "templates": { + "Card 1": { + "Front": "{{Question}}?", + "Back": "{{Answer}}!" + } + } + } + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `updateModelStyling` + +* 修改现有模型的 CSS 样式(通过名称指定)。 + +
+ 请求示例: + + ```json + { + "action": "updateModelStyling", + "version": 6, + "params": { + "model": { + "name": "Custom", + "css": "p { color: blue; }" + } + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `findAndReplaceInModels` + +* 在现有模型中查找并替换字符串(通过模型名称)。通过设置为 true/false 自定义在前面、后面或 CSS 中替换。 + +
+ 请求示例: + + ```json + { + "action": "findAndReplaceInModels", + "version": 6, + "params": { + "model": { + "modelName": "", + "findText": "text_to_replace", + "replaceText": "replace_with_text", + "front": true, + "back": true, + "css": true + } + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": 1, + "error": null + } + ``` +
+ +#### `modelTemplateRename` + +* 重命名现有模型中的模板。 + +
+ 请求示例: + + ```json + { + "action": "modelTemplateRename", + "version": 6, + "params": { + "modelName": "Basic", + "oldTemplateName": "Card 1", + "newTemplateName": "Card 1 renamed" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelTemplateReposition` + +* 重新定位现有模型中的模板。 + + `index` 的值从 0 开始。例如,索引 `0` 将模板放在第一个位置,索引 `2` 将模板放在第三个位置。 + +
+ 请求示例: + + ```json + { + "action": "modelTemplateReposition", + "version": 6, + "params": { + "modelName": "Basic", + "templateName": "Card 1", + "index": 1 + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelTemplateAdd` + +* 通过名称向现有模型添加模板。如果要更新现有模板,请使用 `updateModelTemplates`。 + +
+ 请求示例: + + ```json + { + "action": "modelTemplateAdd", + "version": 6, + "params": { + "modelName": "Basic", + "template": { + "Name": "Card 3", + "Front": "Front html {{Field1}}", + "Back": "Back html {{Field2}}" + } + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelTemplateRemove` + +* 从现有模型中移除模板。 + +
+ 请求示例: + + ```json + { + "action": "modelTemplateRemove", + "version": 6, + "params": { + "modelName": "Basic", + "templateName": "Card 1" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldRename` + +* 重命名给定模型的字段名称。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldRename", + "version": 6, + "params": { + "modelName": "Basic", + "oldFieldName": "Front", + "newFieldName": "FrontRenamed" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldReposition` + +* 重新定位给定模型的字段列表中的字段。 + + `index` 的值从 0 开始。例如,索引 `0` 将字段放在第一个位置,索引 `2` 将字段放在第三个位置。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldReposition", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "Back", + "index": 0 + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldAdd` + +* 在给定模型中创建新字段。 + + 可以选择提供 `index` 值,其工作方式与 `modelFieldReposition` 中的索引完全相同。默认情况下,字段将添加到字段列表的末尾。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldAdd", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "NewField", + "index": 0 + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldRemove` + +* 删除给定模型中的字段。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldRemove", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "Front" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldSetFont` + +* 设置给定模型中字段的字体。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldSetFont", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "Front", + "font": "Courier" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldSetFontSize` + +* 设置给定模型中字段的字体大小。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldSetFontSize", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "Front", + "fontSize": 10 + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldSetDescription` + +* 设置给定模型中字段的描述(当字段为空时在 GUI 编辑器中看到的文本)。 + + 旧版本的 Anki(2.1.49 及以下)没有字段描述。在这种情况下,将返回 `false`。 + +
+ 请求示例: + + ```json + { + "action": "modelFieldSetDescription", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "Front", + "description": "example field description" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +--- + +### 笔记操作 + +#### `addNote` + +* 使用给定的牌组和模型创建一个笔记,包含提供的字段值和标签。成功时返回创建的笔记的标识符, + 失败时返回 `null`。 + + Anki-Connect 可以下载音频、视频和图片文件,并将它们嵌入到新创建的笔记中。相应的 `audio`、`video` 和 `picture` 笔记成员是 + 可选的,可以省略。如果选择包含它们中的任何一个,它们应该包含一个对象或一个对象数组, + 带有必填的 `filename` 字段和 `data`、`path` 或 `url` 之一。有关这些字段的解释,请参阅 `storeMediaFile` 的文档。 + 可以选择提供 `skipHash` 字段,以跳过包含与提供的值匹配的 MD5 哈希的文件。 + 这对于避免保存错误页面和存根文件很有用。 + `fields` 成员是一个字段列表,这些字段应该在卡片显示在 Anki 中时播放音频或视频,或显示图片。 + `options` 组中的 `allowDuplicate` 成员可以设置为 true 以启用添加重复卡片。 + 通常不能添加重复卡片,会触发异常。 + + `options` 中的 `duplicateScope` 成员可用于指定检查重复项的范围。 + 值为 `"deck"` 将只检查目标牌组中的重复项;任何其他值将检查整个集合。 + + `duplicateScopeOptions` 对象可用于指定一些附加设置: + + * `duplicateScopeOptions.deckName` 将指定用于检查重复项的牌组。如果未定义或为 `null`,将使用目标牌组。 + * `duplicateScopeOptions.checkChildren` 将更改是否在子牌组中检查重复卡片。默认值为 `false`。 + * `duplicateScopeOptions.checkAllModels` 指定是否在所有笔记类型中执行重复检查。默认值为 `false`。 + +
+ 请求示例: + + ```json + { + "action": "addNote", + "version": 6, + "params": { + "note": { + "deckName": "Default", + "modelName": "Basic", + "fields": { + "Front": "front content", + "Back": "back content" + }, + "options": { + "allowDuplicate": false, + "duplicateScope": "deck", + "duplicateScopeOptions": { + "deckName": "Default", + "checkChildren": false, + "checkAllModels": false + } + }, + "tags": [ + "yomichan" + ], + "audio": [{ + "url": "https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?kanji=猫&kana=ねこ", + "filename": "yomichan_ねこ_猫.mp3", + "skipHash": "7e2c2f954ef6051373ba916f000168dc", + "fields": [ + "Front" + ] + }], + "video": [{ + "url": "https://cdn.videvo.net/videvo_files/video/free/2015-06/small_watermarked/Contador_Glam_preview.mp4", + "filename": "countdown.mp4", + "skipHash": "4117e8aab0d37534d9c8eac362388bbe", + "fields": [ + "Back" + ] + }], + "picture": [{ + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c7/A_black_cat_named_Tilly.jpg/220px-A_black_cat_named_Tilly.jpg", + "filename": "black_cat.jpg", + "skipHash": "8d6e4646dfae812bf39651b59d7429ce", + "fields": [ + "Back" + ] + }] + } + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": 1496198395707, + "error": null + } + ``` +
+ +#### `addNotes` + +* 使用给定的牌组和模型创建多个笔记,包含提供的字段值和标签。返回一个数组, + 包含创建的笔记的标识符。如有任何错误,将收集并返回所有错误。 +* 有关 `notes` 数组中对象的解释,请参阅 `addNote` 的文档。 + +
+ 请求示例: + + ```json + { + "action":"addNotes", + "version":6, + "params":{ + "notes":[ + { + "deckName":"College::PluginDev", + "modelName":"non_existent_model", + "fields":{ + "Front":"front", + "Back":"bak" + } + }, + { + "deckName":"College::PluginDev", + "modelName":"Basic", + "fields":{ + "Front":"front", + "Back":"bak" + } + } + ] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result":null, + "error":"['model was not found: non_existent_model']" + } + ``` +
+ +#### `canAddNotes` + +* 接受一个对象数组,这些对象定义候选笔记的参数(参见 `addNote`),并返回一个布尔值数组, + 指示相应索引处的参数是否可用于创建新笔记。 + +
+ 请求示例: + + ```json + { + "action": "canAddNotes", + "version": 6, + "params": { + "notes": [ + { + "deckName": "Default", + "modelName": "Basic", + "fields": { + "Front": "front content", + "Back": "back content" + }, + "tags": [ + "yomichan" + ] + } + ] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": [true], + "error": null + } + ``` +
+ +#### `canAddNotesWithErrorDetail` + +* 接受一个对象数组,这些对象定义候选笔记的参数(参见 `addNote`),并返回一个对象数组, + 包含 `canAdd` 和 `error` 字段。 + + * `canAdd` 指示相应索引处的参数是否可用于创建新笔记。 + * `error` 包含无法添加笔记的原因说明。 + +
+ 请求示例: + + ```json + { + "action": "canAddNotesWithErrorDetail", + "version": 6, + "params": { + "notes": [ + { + "deckName": "Default", + "modelName": "Basic", + "fields": { + "Front": "front content", + "Back": "back content" + }, + "tags": [ + "yomichan" + ] + }, + { + "deckName": "Default", + "modelName": "Basic", + "fields": { + "Front": "front content 2", + "Back": "back content 2" + }, + "tags": [ + "yomichan" + ] + } + ] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": [ + { + "canAdd": false, + "error": "cannot create note because it is a duplicate" + }, + { + "canAdd": true + } + ], + "error": null + } + ``` +
+ +#### `updateNoteFields` + +* 修改现有笔记的字段。您还可以包含音频、视频或图片文件,这些文件将添加到笔记中,并带有 + 可选的 `audio`、`video` 或 `picture` 属性。有关 `audio`、`video` 或 `picture` 数组中对象的解释,请参阅 `addNote` 的文档。 + + > **警告**: + > 您不能在 Anki 浏览器中查看您正在更新的笔记,否则 + > 字段将不会更新。有关更多详细信息,请参阅[此问题](https://github.com/FooSoft/anki-connect/issues/82)。 + +
+ 请求示例: + + ```json + { + "action": "updateNoteFields", + "version": 6, + "params": { + "note": { + "id": 1514547547030, + "fields": { + "Front": "new front content", + "Back": "new back content" + }, + "audio": [{ + "url": "https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?kanji=猫&kana=ねこ", + "filename": "yomichan_ねこ_猫.mp3", + "skipHash": "7e2c2f954ef6051373ba916f000168dc", + "fields": [ + "Front" + ] + }] + } + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `updateNote` + +* 修改现有笔记的字段和/或标签。 + 换句话说,结合了 `updateNoteFields` 和 `updateNoteTags` 的功能。 + 请参阅它们的文档以了解所有属性。 + + 可以省略 `fields` 或 `tags` 属性,而不影响另一个。 + 因此有效的 `updateNoteFields` 请求也适用于 `updateNote`。 + 笔记必须具有 `fields` 属性才能更新可选的音频、视频或图片对象。 + + 如果既没有提供 `fields` 也没有提供 `tags`,则该方法将失败。 + 首先更新字段,如果更新标签失败,则不会回滚字段更新。 + 如果更新字段失败,则不会更新标签。 + + > **警告** + > 您不能在 Anki 浏览器中查看您正在更新的笔记,否则 + > 字段将不会更新。有关更多详细信息,请参阅[此问题](https://github.com/FooSoft/anki-connect/issues/82)。 + +
+ 请求示例: + + ```json + { + "action": "updateNote", + "version": 6, + "params": { + "note": { + "id": 1514547547030, + "fields": { + "Front": "new front content", + "Back": "new back content" + }, + "tags": ["new", "tags"] + } + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `updateNoteModel` + +* 更新现有笔记的模型、字段和标签。 + 这允许您更改笔记的模型,用新内容更新其字段,并设置新标签。 + +
+ 请求示例: + + ```json + { + "action": "updateNoteModel", + "version": 6, + "params": { + "note": { + "id": 1514547547030, + "modelName": "NewModel", + "fields": { + "NewField1": "new field 1", + "NewField2": "new field 2", + "NewField3": "new field 3" + }, + "tags": ["new", "updated", "tags"] + } + } + } + ``` + +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` + +
+ +#### `updateNoteTags` + +* 通过笔记 ID 设置笔记的标签。旧标签将被移除。 + +
+ 请求示例: + + ```json + { + "action": "updateNoteTags", + "version": 6, + "params": { + "note": 1483959289817, + "tags": ["european-languages"] + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `getNoteTags` + +* 通过笔记 ID 获取笔记的标签。 + +
+ 请求示例: + + ```json + { + "action": "getNoteTags", + "version": 6, + "params": { + "note": 1483959289817 + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": ["european-languages"], + "error": null + } + ``` +
+ +#### `addTags` + +* 通过笔记 ID 向笔记添加标签。 + +
+ 请求示例: + + ```json + { + "action": "addTags", + "version": 6, + "params": { + "notes": [1483959289817, 1483959291695], + "tags": "european-languages" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `removeTags` + +* 通过笔记 ID 从笔记中移除标签。 + +
+ 请求示例: + + ```json + { + "action": "removeTags", + "version": 6, + "params": { + "notes": [1483959289817, 1483959291695], + "tags": "european-languages" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `getTags` + +* 获取当前用户的标签完整列表。 + +
+ 请求示例: + + ```json + { + "action": "getTags", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": ["european-languages", "idioms"], + "error": null + } + ``` +
+ +#### `clearUnusedTags` + +* 清除当前用户笔记中所有未使用的标签。 + +
+ 请求示例: + + ```json + { + "action": "clearUnusedTags", + "version": 6 + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `replaceTags` + +* 通过笔记 ID 替换笔记中的标签。 + +
+ 请求示例: + + ```json + { + "action": "replaceTags", + "version": 6, + "params": { + "notes": [1483959289817, 1483959291695], + "tag_to_replace": "european-languages", + "replace_with_tag": "french-languages" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `replaceTagsInAllNotes` + +* 替换当前用户所有笔记中的标签。 + +
+ 请求示例: + + ```json + { + "action": "replaceTagsInAllNotes", + "version": 6, + "params": { + "tag_to_replace": "european-languages", + "replace_with_tag": "french-languages" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `findNotes` + +* 返回给定查询的笔记 ID 数组。查询语法[在此处有文档](https://docs.ankiweb.net/searching.html)。 + +
+ 请求示例: + + ```json + { + "action": "findNotes", + "version": 6, + "params": { + "query": "deck:current" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": [1483959289817, 1483959291695], + "error": null + } + ``` +
+ +#### `notesInfo` + +* 返回一个对象列表,每个笔记 ID 包含笔记字段、标签、笔记类型、修改时间、属于该笔记的卡片以及创建笔记的个人资料。 + +
+ 请求示例(笔记 id): + + ```json + { + "action": "notesInfo", + "version": 6, + "params": { + "notes": [1502298033753] + } + } + ``` +
+ +
+ 请求示例(查询): + + ```json + { + "action": "notesInfo", + "version": 6, + "params": { + "query": "deck:current" + } + } + ``` +
+ +
+ 结果示例: + + ```json + { + "result": [ + { + "noteId":1502298033753, + "profile": "User_1", + "modelName": "Basic", + "tags":["tag","another_tag"], + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} + }, + "mod": 1718377864, + "cards": [1498938915662] + } + ], + "error": null + } + ``` +
+ +#### `notesModTime` + +* 返回一个对象列表,每个笔记 ID 包含修改时间。 + +
+ 请求示例: \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ffe4b3 --- /dev/null +++ b/README.md @@ -0,0 +1,4582 @@ +# Anki-Connect + +Anki-Connect enables external applications such as Yomichan to communicate with [Anki](https://apps.ankiweb.net/) over a simple HTTP API. Its capabilities include executing queries against the user's card deck, automatically creating new cards, and more. Anki-Connect is compatible with the latest stable (2.1.x) releases of Anki; older versions (2.0.x and below) are no longer supported. + +## Installation + +The installation process is similar to other Anki plugins and can be accomplished in three steps: + +1. Open the `Install Add-on` dialog by selecting `Tools` | `Add-ons` | `Get Add-ons...` in Anki. +2. Input [2055492159](https://ankiweb.net/shared/info/2055492159) into the text box labeled `Code` and press the `OK` button to proceed. +3. Restart Anki when prompted to do so in order to complete the installation of Anki-Connect. + +Anki must be kept running in the background in order for other applications to be able to use Anki-Connect. You can verify that Anki-Connect is running at any time by accessing `localhost:8765` in your browser. If the server is running, you will see the message `Anki-Connect` displayed in your browser window. + +### Notes for Windows Users + +Windows users may see a firewall nag dialog box appear on Anki startup. This occurs because Anki-Connect runs a local HTTP server in order to enable other applications to connect to it. The host application, Anki, must be unblocked for this plugin to function correctly. + +### Notes for MacOS Users + +Starting with [Mac OS X Mavericks](https://en.wikipedia.org/wiki/OS_X_Mavericks), a feature named *App Nap* has been introduced to the operating system. This feature causes certain applications which are open (but not visible) to be placed in a suspended state. As this behavior causes Anki-Connect to stop working while you have another window in the foreground, App Nap should be disabled for Anki: + +1. Start the Terminal application. +2. Execute the following commands in the terminal window: + ```bash + defaults write net.ankiweb.dtop NSAppSleepDisabled -bool true + defaults write net.ichi2.anki NSAppSleepDisabled -bool true + defaults write org.qt-project.Qt.QtWebEngineCore NSAppSleepDisabled -bool true + ``` +3. Restart Anki. + +## Application Interface for Developers + +Anki-Connect exposes internal Anki features to external applications via an easy to use API. After being installed, this plugin will start an HTTP server on port 8765 whenever Anki is launched. Other applications (including browser extensions) can then communicate with it via HTTP requests. + +By default, Anki-Connect will only bind the HTTP server to the `127.0.0.1` IP address, so that you will only be able to access it from the same host on which it is running. If you need to access it over a network, you can change the binding address in the configuration. Go to Tools->Add-ons->AnkiConnect->Config and change the "webBindAddress" value. For example, you can set it to `0.0.0.0` in order to bind it to all network interfaces on your host. This also requires a restart for Anki. + +### Sample Invocation + +Every request consists of a JSON-encoded object containing an `action`, a `version`, contextual `params`, and a `key` +value used for authentication (which is optional and can be omitted by default). Anki-Connect will respond with an +object containing two fields: `result` and `error`. The `result` field contains the return value of the executed API, +and the `error` field is a description of any exception thrown during API execution (the value `null` is used if +execution completed successfully). + +*Sample successful response*: +```json +{"result": ["Default", "Filtered Deck 1"], "error": null} +``` + +*Samples of failed responses*: +```json +{"result": null, "error": "unsupported action"} +``` +```json +{"result": null, "error": "guiBrowse() got an unexpected keyword argument 'foobar'"} +``` + +For compatibility with clients designed to work with older versions of Anki-Connect, failing to provide a `version` +field in the request will make the version default to 4. Furthermore, when the provided version is level 4 or below, the +API response will only contain the value of the `result`; no `error` field is available for error handling. + +You can use whatever language or tool you like to issue request to Anki-Connect, but a couple of simple examples are +included below as reference. + +#### Curl + +```bash +curl localhost:8765 -X POST -d '{"action": "deckNames", "version": 6}' +``` + +#### Powershell + +```powershell +(Invoke-RestMethod -Uri http://localhost:8765 -Method Post -Body '{"action": "deckNames", "version": 6}').result +``` + +#### Python + +```python +import json +import urllib.request + +def request(action, **params): + return {'action': action, 'params': params, 'version': 6} + +def invoke(action, **params): + requestJson = json.dumps(request(action, **params)).encode('utf-8') + response = json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8765', requestJson))) + 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'] + +invoke('createDeck', deck='test1') +result = invoke('deckNames') +print('got list of decks: {}'.format(result)) +``` + +#### JavaScript + +```javascript +function invoke(action, version, params={}) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.addEventListener('error', () => reject('failed to issue request')); + xhr.addEventListener('load', () => { + try { + const response = JSON.parse(xhr.responseText); + if (Object.getOwnPropertyNames(response).length != 2) { + throw 'response has an unexpected number of fields'; + } + if (!response.hasOwnProperty('error')) { + throw 'response is missing required error field'; + } + if (!response.hasOwnProperty('result')) { + throw 'response is missing required result field'; + } + if (response.error) { + throw response.error; + } + resolve(response.result); + } catch (e) { + reject(e); + } + }); + + xhr.open('POST', 'http://127.0.0.1:8765'); + xhr.send(JSON.stringify({action, version, params})); + }); +} + +await invoke('createDeck', 6, {deck: 'test1'}); +const result = await invoke('deckNames', 6); +console.log(`got list of decks: ${result}`); +``` + +### Authentication + +Anki-Connect supports requiring authentication in order to make API requests. +This support is *disabled* by default, but can be enabled by setting the `apiKey` field of Anki-Config's settings (Tools->Add-ons->AnkiConnect->Config) to a desired string. +If you have done so, you should see the [`requestPermission`](#requestpermission) API request return `true` for `requireApiKey`. +You then must include an additional parameter called `key` in any further API request bodies, whose value must match the configured API key. + +### Hey, could you add a new action to support $FEATURE? + +The primary goal for Anki-Connect was to support real-time flash card creation from the Yomichan browser extension. The current API provides all the required actions to make this happen. I recognise that the role of Anki-Connect has evolved from this original vision, and I am happy to review new feature requests. + +With that said, *this project operates on a self-serve model*. If you would like a new feature, create a PR. I'll review it and if it looks good, it will be merged in. *Requests to add new features without accompanying pull requests will not be serviced*. Make sure that your pull request meets the following criteria: + +* Attempt to match style of the surrounding code. +* Have accompanying documentation with examples. +* Have accompanying tests that verify operation. +* Implement features useful in other applications. + +## Supported Actions + +Documentation for currently supported actions is split up by category and is referenced below. Note that deprecated APIs will continue to function despite not being listed on this page as long as your request is labeled with a version number corresponding to when the API was available for use. Search parameters are passed to Anki, check the docs for more information: https://docs.ankiweb.net/searching.html + +* [Card Actions](#card-actions) +* [Deck Actions](#deck-actions) +* [Graphical Actions](#graphical-actions) +* [Media Actions](#media-actions) +* [Miscellaneous Actions](#miscellaneous-actions) +* [Model Actions](#model-actions) +* [Note Actions](#note-actions) +* [Statistic Actions](#statistic-actions) + +--- + +### Card Actions + +#### `getEaseFactors` + +* Returns an array with the ease factor for each of the given cards (in the same order). + +
+ Sample request: + + ```json + { + "action": "getEaseFactors", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [4100, 3900], + "error": null + } + ``` +
+ +#### `setEaseFactors` + +* Sets ease factor of cards by card ID; returns `true` if successful (all cards existed) or `false` otherwise. + +
+ Sample request: + + ```json + { + "action": "setEaseFactors", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217], + "easeFactors": [4100, 3900] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [true, true], + "error": null + } + ``` +
+ + +#### `setSpecificValueOfCard` + +* Sets specific value of a single card. Given the risk of wreaking havor in the database when changing some of the values of a card, some of the keys require the argument "warning_check" set to True. + This can be used to set a card's flag, change it's ease factor, change the review order in a filtered deck and change the column "data" (not currently used by anki apparantly), and many other values. + A list of values and explanation of their respective utility can be found at [AnkiDroid's wiki](https://github.com/ankidroid/Anki-Android/wiki/Database-Structure). + +
+ Sample request: + + ```json + { + "action": "setSpecificValueOfCard", + "version": 6, + "params": { + "card": 1483959291685, + "keys": ["flags", "odue"], + "newValues": ["1", "-100"] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [true, true], + "error": null + } + ``` +
+ + +#### `suspend` + +* Suspend cards by card ID; returns `true` if successful (at least one card wasn't already suspended) or `false` + otherwise. + +
+ Sample request: + + ```json + { + "action": "suspend", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `unsuspend` + +* Unsuspend cards by card ID; returns `true` if successful (at least one card was previously suspended) or `false` + otherwise. + +
+ Sample request: + + ```json + { + "action": "unsuspend", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `suspended` + +* Check if card is suspended by its ID. Returns `true` if suspended, `false` otherwise. + +
+ Sample request: + + ```json + { + "action": "suspended", + "version": 6, + "params": { + "card": 1483959293217 + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `areSuspended` + +* Returns an array indicating whether each of the given cards is suspended (in the same order). If card doesn't + exist returns `null`. + +
+ Sample request: + + ```json + { + "action": "areSuspended", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217, 1234567891234] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [false, true, null], + "error": null + } + ``` +
+ +#### `areDue` + +* Returns an array indicating whether each of the given cards is due (in the same order). *Note*: cards in the + learning queue with a large interval (over 20 minutes) are treated as not due until the time of their interval has + passed, to match the way Anki treats them when reviewing. + +
+ Sample request: + + ```json + { + "action": "areDue", + "version": 6, + "params": { + "cards": [1483959291685, 1483959293217] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [false, true], + "error": null + } + ``` +
+ +#### `getIntervals` + +* Returns an array of the most recent intervals for each given card ID, or a 2-dimensional array of all the intervals + for each given card ID when `complete` is `true`. Negative intervals are in seconds and positive intervals in days. + +
+ Sample request 1: + + ```json + { + "action": "getIntervals", + "version": 6, + "params": { + "cards": [1502298033753, 1502298036657] + } + } + ``` +
+ +
+ Sample result 1: + + ```json + { + "result": [-14400, 3], + "error": null + } + ``` +
+ +
+ Sample request 2: + + ```json + { + "action": "getIntervals", + "version": 6, + "params": { + "cards": [1502298033753, 1502298036657], + "complete": true + } + } + ``` +
+ +
+ Sample result 2: + + ```json + { + "result": [ + [-120, -180, -240, -300, -360, -14400], + [-120, -180, -240, -300, -360, -14400, 1, 3] + ], + "error": null + } + ``` +
+ +#### `findCards` + +* Returns an array of card IDs for a given query. Functionally identical to `guiBrowse` but doesn't use the GUI for + better performance. + +
+ Sample request: + + ```json + { + "action": "findCards", + "version": 6, + "params": { + "query": "deck:current" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [1494723142483, 1494703460437, 1494703479525], + "error": null + } + ``` +
+ +#### `cardsToNotes` + +* Returns an unordered array of note IDs for the given card IDs. For cards with the same note, the ID is only given + once in the array. + +
+ Sample request: + + ```json + { + "action": "cardsToNotes", + "version": 6, + "params": { + "cards": [1502098034045, 1502098034048, 1502298033753] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [1502098029797, 1502298025183], + "error": null + } + ``` +
+ +#### `cardsModTime` + +* Returns a list of objects containings for each card ID the modification time. + This function is about 15 times faster than executing `cardsInfo`. + +
+ Sample request: + + ```json + { + "action": "cardsModTime", + "version": 6, + "params": { + "cards": [1498938915662, 1502098034048] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [ + { + "cardId": 1498938915662, + "mod": 1629454092 + } + ], + "error": null + } + ``` +
+ + +#### `cardsInfo` + +* Returns a list of objects containing for each card ID the card fields, front and back sides including CSS, note + type, the note that the card belongs to, and deck name, last modification timestamp as well as ease and interval. + +
+ Sample request: + + ```json + { + "action": "cardsInfo", + "version": 6, + "params": { + "cards": [1498938915662, 1502098034048] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [ + { + "answer": "back content", + "question": "front content", + "deckName": "Default", + "modelName": "Basic", + "fieldOrder": 1, + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} + }, + "css":"p {font-family:Arial;}", + "cardId": 1498938915662, + "interval": 16, + "note":1502298033753, + "ord": 1, + "type": 0, + "queue": 0, + "due": 1, + "reps": 1, + "lapses": 0, + "left": 6, + "mod": 1629454092 + }, + { + "answer": "back content", + "question": "front content", + "deckName": "Default", + "modelName": "Basic", + "fieldOrder": 0, + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} + }, + "css":"p {font-family:Arial;}", + "cardId": 1502098034048, + "interval": 23, + "note":1502298033753, + "ord": 1, + "type": 0, + "queue": 0, + "due": 1, + "reps": 1, + "lapses": 0, + "left": 6 + } + ], + "error": null + } + ``` +
+ +#### `forgetCards` + +* Forget cards, making the cards new again. + +
+ Sample request: + + ```json + { + "action": "forgetCards", + "version": 6, + "params": { + "cards": [1498938915662, 1502098034048] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `relearnCards` + +* Make cards be "relearning". + +
+ Sample request: + + ```json + { + "action": "relearnCards", + "version": 6, + "params": { + "cards": [1498938915662, 1502098034048] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `answerCards` + +* Answer cards. Ease is between 1 (Again) and 4 (Easy). Will start the timer immediately before answering. Returns `true` if card exists, `false` otherwise. + +
+ Sample request: + + ```json + { + "action": "answerCards", + "version": 6, + "params": { + "answers": [ + { + "cardId": 1498938915662, + "ease": 2 + }, + { + "cardId": 1502098034048, + "ease": 4 + } + ] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [true, true], + "error": null + } + ``` +
+ +#### `setDueDate` + +* Set Due Date. Turns cards into review cards if they are new, and makes them due on a certain date. + * 0 = today + * 1! = tomorrow + change interval to 1 + * 3-7 = random choice of 3-7 days + +
+ Sample request: + + ```json + { + "action": "setDueDate", + "version": 6, + "params": { + "cards": [1498938915662, 1502098034048], + "days": "3-7" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +--- + +### Deck Actions + +#### `deckNames` + +* Gets the complete list of deck names for the current user. + +
+ Sample request: + + ```json + { + "action": "deckNames", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": ["Default"], + "error": null + } + ``` +
+ +#### `deckNamesAndIds` + +* Gets the complete list of deck names and their respective IDs for the current user. + +
+ Sample request: + + ```json + { + "action": "deckNamesAndIds", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": {"Default": 1}, + "error": null + } + ``` +
+ +#### `getDecks` + +* Accepts an array of card IDs and returns an object with each deck name as a key, and its value an array of the given + cards which belong to it. + +
+ Sample request: + + ```json + { + "action": "getDecks", + "version": 6, + "params": { + "cards": [1502298036657, 1502298033753, 1502032366472] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": { + "Default": [1502032366472], + "Japanese::JLPT N3": [1502298036657, 1502298033753] + }, + "error": null + } + ``` +
+ +#### `createDeck` + +* Create a new empty deck. Will not overwrite a deck that exists with the same name. + +
+ Sample request: + + ```json + { + "action": "createDeck", + "version": 6, + "params": { + "deck": "Japanese::Tokyo" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": 1519323742721, + "error": null + } + ``` +
+ +#### `changeDeck` + +* Moves cards with the given IDs to a different deck, creating the deck if it doesn't exist yet. + +
+ Sample request: + + ```json + { + "action": "changeDeck", + "version": 6, + "params": { + "cards": [1502098034045, 1502098034048, 1502298033753], + "deck": "Japanese::JLPT N3" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `deleteDecks` + +* Deletes decks with the given names. + The argument `cardsToo` *must* be specified and set to `true`. + +
+ Sample request: + + ```json + { + "action": "deleteDecks", + "version": 6, + "params": { + "decks": ["Japanese::JLPT N5", "Easy Spanish"], + "cardsToo": true + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `getDeckConfig` + +* Gets the configuration group object for the given deck. + +
+ Sample request: + + ```json + { + "action": "getDeckConfig", + "version": 6, + "params": { + "deck": "Default" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": { + "lapse": { + "leechFails": 8, + "delays": [10], + "minInt": 1, + "leechAction": 0, + "mult": 0 + }, + "dyn": false, + "autoplay": true, + "mod": 1502970872, + "id": 1, + "maxTaken": 60, + "new": { + "bury": true, + "order": 1, + "initialFactor": 2500, + "perDay": 20, + "delays": [1, 10], + "separate": true, + "ints": [1, 4, 7] + }, + "name": "Default", + "rev": { + "bury": true, + "ivlFct": 1, + "ease4": 1.3, + "maxIvl": 36500, + "perDay": 100, + "minSpace": 1, + "fuzz": 0.05 + }, + "timer": 0, + "replayq": true, + "usn": -1 + }, + "error": null + } + ``` +
+ +#### `saveDeckConfig` + +* Saves the given configuration group, returning `true` on success or `false` if the ID of the configuration group is + invalid (such as when it does not exist). + +
+ Sample request: + + ```json + { + "action": "saveDeckConfig", + "version": 6, + "params": { + "config": { + "lapse": { + "leechFails": 8, + "delays": [10], + "minInt": 1, + "leechAction": 0, + "mult": 0 + }, + "dyn": false, + "autoplay": true, + "mod": 1502970872, + "id": 1, + "maxTaken": 60, + "new": { + "bury": true, + "order": 1, + "initialFactor": 2500, + "perDay": 20, + "delays": [1, 10], + "separate": true, + "ints": [1, 4, 7] + }, + "name": "Default", + "rev": { + "bury": true, + "ivlFct": 1, + "ease4": 1.3, + "maxIvl": 36500, + "perDay": 100, + "minSpace": 1, + "fuzz": 0.05 + }, + "timer": 0, + "replayq": true, + "usn": -1 + } + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `setDeckConfigId` + +* Changes the configuration group for the given decks to the one with the given ID. Returns `true` on success or + `false` if the given configuration group or any of the given decks do not exist. + +
+ Sample request: + + ```json + { + "action": "setDeckConfigId", + "version": 6, + "params": { + "decks": ["Default"], + "configId": 1 + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `cloneDeckConfigId` + +* Creates a new configuration group with the given name, cloning from the group with the given ID, or from the default + group if this is unspecified. Returns the ID of the new configuration group, or `false` if the specified group to + clone from does not exist. + +
+ Sample request: + + ```json + { + "action": "cloneDeckConfigId", + "version": 6, + "params": { + "name": "Copy of Default", + "cloneFrom": 1 + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": 1502972374573, + "error": null + } + ``` +
+ +#### `removeDeckConfigId` + +* Removes the configuration group with the given ID, returning `true` if successful, or `false` if attempting to + remove either the default configuration group (ID = 1) or a configuration group that does not exist. + +
+ Sample request: + + ```json + { + "action": "removeDeckConfigId", + "version": 6, + "params": { + "configId": 1502972374573 + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `getDeckStats` + +* Gets statistics such as total cards and cards due for the given decks. + +
+ Sample request: + + ```json + { + "action": "getDeckStats", + "version": 6, + "params": { + "decks": ["Japanese::JLPT N5", "Easy Spanish"] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": { + "1651445861967": { + "deck_id": 1651445861967, + "name": "Japanese::JLPT N5", + "new_count": 20, + "learn_count": 0, + "review_count": 0, + "total_in_deck": 1506 + }, + "1651445861960": { + "deck_id": 1651445861960, + "name": "Easy Spanish", + "new_count": 26, + "learn_count": 10, + "review_count": 5, + "total_in_deck": 852 + } + }, + "error": null + } + ``` +
+ +--- + +### Graphical Actions + +#### `guiBrowse` + +* Invokes the *Card Browser* dialog and searches for a given query. Returns an array of identifiers of the cards that + were found. Query syntax is [documented here](https://docs.ankiweb.net/searching.html). + + Optionally, the `reorderCards` property can be provided to reorder the cards shown in the *Card Browser*. + This is an array including the `order` and `columnId` objects. `order` can be either `ascending` or `descending` while `columnId` can be one of several column identifiers (as documented in the [Anki source code](https://github.com/ankitects/anki/blob/main/rslib/src/browser_table.rs)). + The specified column needs to be visible in the *Card Browser*. + +
+ Sample request: + + ```json + { + "action": "guiBrowse", + "version": 6, + "params": { + "query": "deck:current", + "reorderCards": { + "order": "descending", + "columnId": "noteCrt" + } + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [1494723142483, 1494703460437, 1494703479525], + "error": null + } + ``` +
+ +#### `guiSelectCard` + +* Finds the open instance of the *Card Browser* dialog and selects a card given a card identifier. + Returns `true` if the *Card Browser* is open, `false` otherwise. + +
+ Sample request: + + ```json + { + "action": "guiSelectCard", + "version": 6, + "params": { + "card": 1494723142483 + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiSelectedNotes` + +* Finds the open instance of the *Card Browser* dialog and returns an array of identifiers of the notes that are + selected. Returns an empty list if the browser is not open. + +
+ Sample request: + + ```json + { + "action": "guiSelectedNotes", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [1494723142483, 1494703460437, 1494703479525], + "error": null + } + ``` +
+ +#### `guiAddCards` + +* Invokes the *Add Cards* dialog, presets the note using the given deck and model, with the provided field values and tags. + Invoking it multiple times closes the old window and _reopen the window_ with the new provided values. + + Audio, video, and picture files can be embedded into the fields via the `audio`, `video`, and `picture` keys, respectively. + Refer to the documentation of `addNote` and `storeMediaFile` for an explanation of these fields. + + The result is the ID of the note which would be added, if the user chose to confirm the *Add Cards* dialogue. + +
+ Sample request: + + ```json + { + "action": "guiAddCards", + "version": 6, + "params": { + "note": { + "deckName": "Default", + "modelName": "Cloze", + "fields": { + "Text": "The capital of Romania is {{c1::Bucharest}}", + "Extra": "Romania is a country in Europe" + }, + "tags": [ + "countries" + ], + "picture": [{ + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/13/EU-Romania.svg/285px-EU-Romania.svg.png", + "filename": "romania.png", + "fields": [ + "Extra" + ] + }] + } + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": 1496198395707, + "error": null + } + ``` +
+ +#### `guiEditNote` + +* Opens the *Edit* dialog with a note corresponding to given note ID. + The dialog is similar to the *Edit Current* dialog, but: + + * has a Preview button to preview the cards for the note + * has a Browse button to open the browser with these cards + * has Previous/Back buttons to navigate the history of the dialog + * has no bar with the Close button + +
+ Sample request: + + ```json + { + "action": "guiEditNote", + "version": 6, + "params": { + "note": 1649198355435 + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `guiCurrentCard` + +* Returns information about the current card or `null` if not in review mode. + +
+ Sample request: + + ```json + { + "action": "guiCurrentCard", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": { + "answer": "back content", + "question": "front content", + "deckName": "Default", + "modelName": "Basic", + "fieldOrder": 0, + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} + }, + "template": "Forward", + "cardId": 1498938915662, + "buttons": [1, 2, 3], + "nextReviews": ["<1m", "<10m", "4d"] + }, + "error": null + } + ``` +
+ +#### `guiStartCardTimer` + +* Starts or resets the `timerStarted` value for the current card. This is useful for deferring the start time to when + it is displayed via the API, allowing the recorded time taken to answer the card to be more accurate when calling + `guiAnswerCard`. + +
+ Sample request: + + ```json + { + "action": "guiStartCardTimer", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiShowQuestion` + +* Shows question text for the current card; returns `true` if in review mode or `false` otherwise. + +
+ Sample request: + + ```json + { + "action": "guiShowQuestion", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiShowAnswer` + +* Shows answer text for the current card; returns `true` if in review mode or `false` otherwise. + +
+ Sample request: + + ```json + { + "action": "guiShowAnswer", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiAnswerCard` + +* Answers the current card; returns `true` if succeeded or `false` otherwise. Note that the answer for the current + card must be displayed before before any answer can be accepted by Anki. + +
+ Sample request: + + ```json + { + "action": "guiAnswerCard", + "version": 6, + "params": { + "ease": 1 + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiUndo` + +* Undo the last action / card; returns `true` if succeeded or `false` otherwise. + +
+ Sample request: + + ```json + { + "action": "guiUndo", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiDeckOverview` + +* Opens the *Deck Overview* dialog for the deck with the given name; returns `true` if succeeded or `false` otherwise. + +
+ Sample request: + + ```json + { + "action": "guiDeckOverview", + "version": 6, + "params": { + "name": "Default" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiDeckBrowser` + +* Opens the *Deck Browser* dialog. + +
+ Sample request: + + ```json + { + "action": "guiDeckBrowser", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `guiDeckReview` + +* Starts review for the deck with the given name; returns `true` if succeeded or `false` otherwise. + +
+ Sample request: + + ```json + { + "action": "guiDeckReview", + "version": 6, + "params": { + "name": "Default" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `guiImportFile` + +* Invokes the *Import... (Ctrl+Shift+I)* dialog with an optional file path. Brings up the dialog for user to review the import. Supports all file types that Anki supports. Brings open file dialog if no path is provided. Forward slashes must be used in the path on Windows. Only supported for Anki 2.1.52+. + +
+ Sample request: + + ```json + { + "action": "guiImportFile", + "version": 6, + "params": { + "path": "C:/Users/Desktop/cards.txt" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `guiExitAnki` + +* Schedules a request to gracefully close Anki. This operation is asynchronous, so it will return immediately and + won't wait until the Anki process actually terminates. + +
+ Sample request: + + ```json + { + "action": "guiExitAnki", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `guiCheckDatabase` + +* Requests a database check, but returns immediately without waiting for the check to complete. Therefore, the action will always return `true` even if errors are detected during the database check. + +
+ Sample request: + + ```json + { + "action": "guiCheckDatabase", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +--- + +### Media Actions + +#### `storeMediaFile` + +* Stores a file with the specified base64-encoded contents inside the media folder. Alternatively you can specify a + absolute file path, or a url from where the file shell be downloaded. If more than one of `data`, `path` and `url` are provided, the `data` field will be used first, then `path`, and finally `url`. To prevent Anki from removing files not used by any cards (e.g. for configuration files), prefix the filename with an underscore. These files are still synchronized to AnkiWeb. + Any existing file with the same name is deleted by default. Set `deleteExisting` to false to prevent that + by [letting Anki give the new file a non-conflicting name](https://github.com/ankitects/anki/blob/aeba725d3ea9628c73300648f748140db3fdd5ed/rslib/src/media/files.rs#L194). + +
+ Sample request (relative path): + + ```json + { + "action": "storeMediaFile", + "version": 6, + "params": { + "filename": "_hello.txt", + "data": "SGVsbG8sIHdvcmxkIQ==" + } + } + ``` + + *Content of `_hello.txt`*: + + ``` + Hello world! + ``` +
+ +
+ Sample result (relative path): + + ```json + { + "result": "_hello.txt", + "error": null + } + ``` +
+ +
+ Sample request (absolute path): + + ```json + { + "action": "storeMediaFile", + "version": 6, + "params": { + "filename": "_hello.txt", + "path": "/path/to/file" + } + } + ``` +
+ +
+ Sample result (absolute path): + + ```json + { + "result": "_hello.txt", + "error": null + } + ``` +
+ +
+ Sample request (url): + + ```json + { + "action": "storeMediaFile", + "version": 6, + "params": { + "filename": "_hello.txt", + "url": "https://url.to.file" + } + } + ``` +
+ +
+ Sample result (url): + + ```json + { + "result": "_hello.txt", + "error": null + } + ``` +
+ +#### `retrieveMediaFile` + +* Retrieves the base64-encoded contents of the specified file, returning `false` if the file does not exist. + +
+ Sample request: + + ```json + { + "action": "retrieveMediaFile", + "version": 6, + "params": { + "filename": "_hello.txt" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": "SGVsbG8sIHdvcmxkIQ==", + "error": null + } + ``` +
+ +#### `getMediaFilesNames` + +* Gets the names of media files matched the pattern. Returning all names by default. + +
+ Sample request: + + ```json + { + "action": "getMediaFilesNames", + "version": 6, + "params": { + "pattern": "_hell*.txt" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": ["_hello.txt"], + "error": null + } + ``` +
+ +#### `getMediaDirPath` + +* Gets the full path to the `collection.media` folder of the currently opened profile. + +
+ Sample request: + + ```json + { + "action": "getMediaDirPath", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": "/home/user/.local/share/Anki2/Main/collection.media", + "error": null + } + ``` +
+ +#### `deleteMediaFile` + +* Deletes the specified file inside the media folder. + +
+ Sample request: + + ```json + { + "action": "deleteMediaFile", + "version": 6, + "params": { + "filename": "_hello.txt" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +--- + +### Miscellaneous Actions + +#### `requestPermission` + +* Requests permission to use the API exposed by this plugin. This method does not require the API key, and is the + only one that accepts requests from any origin; the other methods only accept requests from trusted origins, + which are listed under `webCorsOriginList` in the add-on config. `localhost` is trusted by default. + + Calling this method from an untrusted origin will display a popup in Anki asking the user whether they want to + allow your origin to use the API; calls from trusted origins will return the result without displaying the popup. + When denying permission, the user may also choose to ignore further permission requests from that origin. These + origins end up in the `ignoreOriginList`, editable via the add-on config. + + The result always contains the `permission` field, which in turn contains either the string `granted` or `denied`, + corresponding to whether your origin is trusted. If your origin is trusted, the fields `requireApiKey` (`true` if + required) and `version` will also be returned. + + This should be the first call you make to make sure that your application and Anki-Connect are able to communicate + properly with each other. New versions of Anki-Connect are backwards compatible; as long as you are using actions + which are available in the reported Anki-Connect version or earlier, everything should work fine. + +
+ Sample request: + + ```json + { + "action": "requestPermission", + "version": 6 + } + ``` +
+ +
+ Sample results: + + ```json + { + "result": { + "permission": "granted", + "requireApiKey": false, + "version": 6 + }, + "error": null + } + ``` + + ```json + { + "result": { + "permission": "denied" + }, + "error": null + } + ``` +
+ +#### `version` + +* Gets the version of the API exposed by this plugin. Currently versions `1` through `6` are defined. + +
+ Sample request: + + ```json + { + "action": "version", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": 6, + "error": null + } + ``` +
+ + +#### `apiReflect` + +* Gets information about the AnkiConnect APIs available. The request supports the following params: + + * `scopes` - An array of scopes to get reflection information about. + The only currently supported value is `"actions"`. + * `actions` - Either `null` or an array of API method names to check for. + If the value is `null`, the result will list all of the available API actions. + If the value is an array of strings, the result will only contain actions which were in this array. + + The result will contain a list of which scopes were used and a value for each scope. + For example, the `"actions"` scope will contain a `"actions"` property which contains a list of supported action names. + +
+ Sample request: + + ```json + { + "action": "apiReflect", + "version": 6, + "params": { + "scopes": ["actions", "invalidType"], + "actions": ["apiReflect", "invalidMethod"] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": { + "scopes": ["actions"], + "actions": ["apiReflect"] + }, + "error": null + } + ``` +
+ +#### `sync` + +* Synchronizes the local Anki collections with AnkiWeb. + +
+ Sample request: + + ```json + { + "action": "sync", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `getProfiles` + +* Retrieve the list of profiles. + +
+ Sample request: + + ```json + { + "action": "getProfiles", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": ["User 1"], + "error": null + } + ``` +
+ +#### `getActiveProfile` + +* Retrieve the active profile. + +
+ Sample request: + + ```json + { + "action": "getActiveProfile", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": "User 1", + "error": null + } + ``` +
+ + +#### `loadProfile` + +* Selects the profile specified in request. + +
+ Sample request: + + ```json + { + "action": "loadProfile", + "version": 6, + "params": { + "name": "user1" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `multi` + +* Performs multiple actions in one request, returning an array with the response of each action (in the given order). + +
+ Sample request: + + ```json + { + "action": "multi", + "version": 6, + "params": { + "actions": [ + { + "action": "deckNames" + }, + { + "action": "deckNames", + "version": 6 + }, + { + "action": "invalidAction", + "params": {"useless": "param"} + }, + { + "action": "invalidAction", + "params": {"useless": "param"}, + "version": 6 + } + ] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [ + ["Default"], + {"result": ["Default"], "error": null}, + {"result": null, "error": "unsupported action"}, + {"result": null, "error": "unsupported action"} + ], + "error": null + } + ``` +
+ +#### `exportPackage` + +* Exports a given deck in `.apkg` format. Returns `true` if successful or `false` otherwise. The optional property + `includeSched` (default is `false`) can be specified to include the cards' scheduling data. + +
+ Sample request: + + ```json + { + "action": "exportPackage", + "version": 6, + "params": { + "deck": "Default", + "path": "/data/Deck.apkg", + "includeSched": true + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `importPackage` + +* Imports a file in `.apkg` format into the collection. Returns `true` if successful or `false` otherwise. + Note that the file path is relative to Anki's collection.media folder, not to the client. + +
+ Sample request: + + ```json + { + "action": "importPackage", + "version": 6, + "params": { + "path": "/data/Deck.apkg" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +#### `reloadCollection` + +* Tells anki to reload all data from the database. + +
+ Sample request: + + ```json + { + "action": "reloadCollection", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +--- + +### Model Actions + +#### `modelNames` + +* Gets the complete list of model names for the current user. + +
+ Sample request: + + ```json + { + "action": "modelNames", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": ["Basic", "Basic (and reversed card)"], + "error": null + } + ``` +
+ +#### `modelNamesAndIds` + +* Gets the complete list of model names and their corresponding IDs for the current user. + +
+ Sample request: + + ```json + { + "action": "modelNamesAndIds", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": { + "Basic": 1483883011648, + "Basic (and reversed card)": 1483883011644, + "Basic (optional reversed card)": 1483883011631, + "Cloze": 1483883011630 + }, + "error": null + } + ``` +
+ +#### `findModelsById` + +* Gets a list of models for the provided model IDs from the current user. + +
+ Sample request: + + ```json + { + "action": "findModelsById", + "version": 6, + "params": { + "modelIds": [1704387367119, 1704387398570] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [ + { + "id": 1704387367119, + "name": "Basic", + "type": 0, + "mod": 1704387367, + "usn": -1, + "sortf": 0, + "did": null, + "tmpls": [ + { + "name": "Card 1", + "ord": 0, + "qfmt": "{{Front}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": 9176047152973362695 + } + ], + "flds": [ + { + "name": "Front", + "ord": 0, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": 2453723143453745216, + "tag": null, + "preventDeletion": false + }, + { + "name": "Back", + "ord": 1, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": -4853200230425436781, + "tag": null, + "preventDeletion": false + } + ], + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexPost": "\\end{document}", + "latexsvg": false, + "req": [ + [ + 0, + "any", + [ + 0 + ] + ] + ], + "originalStockKind": 1 + }, + { + "id": 1704387398570, + "name": "Basic (and reversed card)", + "type": 0, + "mod": 1704387398, + "usn": -1, + "sortf": 0, + "did": null, + "tmpls": [ + { + "name": "Card 1", + "ord": 0, + "qfmt": "{{Front}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": 1689886528158874152 + }, + { + "name": "Card 2", + "ord": 1, + "qfmt": "{{Back}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Front}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": -7839609225644824587 + } + ], + "flds": [ + { + "name": "Front", + "ord": 0, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": -7787837672455357996, + "tag": null, + "preventDeletion": false + }, + { + "name": "Back", + "ord": 1, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": 6364828289839985081, + "tag": null, + "preventDeletion": false + } + ], + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexPost": "\\end{document}", + "latexsvg": false, + "req": [ + [ + 0, + "any", + [ + 0 + ] + ], + [ + 1, + "any", + [ + 1 + ] + ] + ], + "originalStockKind": 1 + } + ], + "error": null + } + ``` +
+ + +#### `findModelsByName` + +* Gets a list of models for the provided model names from the current user. + +
+ Sample request: + + ```json + { + "action": "findModelsByName", + "version": 6, + "params": { + "modelNames": ["Basic", "Basic (and reversed card)"] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [ + { + "id": 1704387367119, + "name": "Basic", + "type": 0, + "mod": 1704387367, + "usn": -1, + "sortf": 0, + "did": null, + "tmpls": [ + { + "name": "Card 1", + "ord": 0, + "qfmt": "{{Front}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": 9176047152973362695 + } + ], + "flds": [ + { + "name": "Front", + "ord": 0, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": 2453723143453745216, + "tag": null, + "preventDeletion": false + }, + { + "name": "Back", + "ord": 1, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": -4853200230425436781, + "tag": null, + "preventDeletion": false + } + ], + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexPost": "\\end{document}", + "latexsvg": false, + "req": [ + [ + 0, + "any", + [ + 0 + ] + ] + ], + "originalStockKind": 1 + }, + { + "id": 1704387398570, + "name": "Basic (and reversed card)", + "type": 0, + "mod": 1704387398, + "usn": -1, + "sortf": 0, + "did": null, + "tmpls": [ + { + "name": "Card 1", + "ord": 0, + "qfmt": "{{Front}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": 1689886528158874152 + }, + { + "name": "Card 2", + "ord": 1, + "qfmt": "{{Back}}", + "afmt": "{{FrontSide}}\n\n
\n\n{{Front}}", + "bqfmt": "", + "bafmt": "", + "did": null, + "bfont": "", + "bsize": 0, + "id": -7839609225644824587 + } + ], + "flds": [ + { + "name": "Front", + "ord": 0, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": -7787837672455357996, + "tag": null, + "preventDeletion": false + }, + { + "name": "Back", + "ord": 1, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "description": "", + "plainText": false, + "collapsed": false, + "excludeFromSearch": false, + "id": 6364828289839985081, + "tag": null, + "preventDeletion": false + } + ], + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexPost": "\\end{document}", + "latexsvg": false, + "req": [ + [ + 0, + "any", + [ + 0 + ] + ], + [ + 1, + "any", + [ + 1 + ] + ] + ], + "originalStockKind": 1 + } + ], + "error": null + } + ``` +
+ +#### `modelFieldNames` + +* Gets the complete list of field names for the provided model name. + +
+ Sample request: + + ```json + { + "action": "modelFieldNames", + "version": 6, + "params": { + "modelName": "Basic" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": ["Front", "Back"], + "error": null + } + ``` +
+ +#### `modelFieldDescriptions` + +* Gets the complete list of field descriptions (the text seen in the gui editor when a field is empty) for the provided model name. + +
+ Sample request: + + ```json + { + "action": "modelFieldDescriptions", + "version": 6, + "params": { + "modelName": "Basic" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": ["", ""], + "error": null + } + ``` +
+ +#### `modelFieldFonts` + +* Gets the complete list of fonts along with their font sizes. + +
+ Sample request: + + ```json + { + "action": "modelFieldFonts", + "version": 6, + "params": { + "modelName": "Basic" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": { + "Front": { + "font": "Arial", + "size": 20 + }, + "Back": { + "font": "Arial", + "size": 20 + } + }, + "error": null + } + ``` +
+ +#### `modelFieldsOnTemplates` + +* Returns an object indicating the fields on the question and answer side of each card template for the given model + name. The question side is given first in each array. + +
+ Sample request: + + ```json + { + "action": "modelFieldsOnTemplates", + "version": 6, + "params": { + "modelName": "Basic (and reversed card)" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": { + "Card 1": [["Front"], ["Back"]], + "Card 2": [["Back"], ["Front"]] + }, + "error": null + } + ``` +
+ +#### `createModel` + +* Creates a new model to be used in Anki. User must provide the `modelName`, `inOrderFields` and `cardTemplates` to be + used in the model. There are optional fields `css` and `isCloze`. If not specified, `css` will use the default Anki css and `isCloze` will be equal to `false`. If `isCloze` is `true` then model will be created as Cloze. + + Optionally the `Name` field can be provided for each entry of `cardTemplates`. By default the + card names will be `Card 1`, `Card 2`, and so on. + +
+ Sample request: + + ```json + { + "action": "createModel", + "version": 6, + "params": { + "modelName": "newModelName", + "inOrderFields": ["Field1", "Field2", "Field3"], + "css": "Optional CSS with default to builtin css", + "isCloze": false, + "cardTemplates": [ + { + "Name": "My Card 1", + "Front": "Front html {{Field1}}", + "Back": "Back html {{Field2}}" + } + ] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result":{ + "sortf":0, + "did":1, + "latexPre":"\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexPost":"\\end{document}", + "mod":1551462107, + "usn":-1, + "vers":[ + + ], + "type":0, + "css":".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "name":"TestApiModel", + "flds":[ + { + "name":"Field1", + "ord":0, + "sticky":false, + "rtl":false, + "font":"Arial", + "size":20, + "media":[ + + ] + }, + { + "name":"Field2", + "ord":1, + "sticky":false, + "rtl":false, + "font":"Arial", + "size":20, + "media":[ + + ] + } + ], + "tmpls":[ + { + "name":"My Card 1", + "ord":0, + "qfmt":"", + "afmt":"This is the back of the card {{Field2}}", + "did":null, + "bqfmt":"", + "bafmt":"" + } + ], + "tags":[ + + ], + "id":1551462107104, + "req":[ + [ + 0, + "none", + [ + + ] + ] + ] + }, + "error":null + } + ``` +
+ +#### `modelTemplates` + +* Returns an object indicating the template content for each card connected to the provided model by name. + +
+ Sample request: + + ```json + { + "action": "modelTemplates", + "version": 6, + "params": { + "modelName": "Basic (and reversed card)" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": { + "Card 1": { + "Front": "{{Front}}", + "Back": "{{FrontSide}}\n\n
\n\n{{Back}}" + }, + "Card 2": { + "Front": "{{Back}}", + "Back": "{{FrontSide}}\n\n
\n\n{{Front}}" + } + }, + "error": null + } + ``` +
+ +#### `modelStyling` + +* Gets the CSS styling for the provided model by name. + +
+ Sample request: + + ```json + { + "action": "modelStyling", + "version": 6, + "params": { + "modelName": "Basic (and reversed card)" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": { + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n" + }, + "error": null + } + ``` +
+ +#### `updateModelTemplates` + +* Modify the templates of an existing model by name. Only specifies cards and specified sides will be modified. + If an existing card or side is not included in the request, it will be left unchanged. + +
+ Sample request: + + ```json + { + "action": "updateModelTemplates", + "version": 6, + "params": { + "model": { + "name": "Custom", + "templates": { + "Card 1": { + "Front": "{{Question}}?", + "Back": "{{Answer}}!" + } + } + } + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `updateModelStyling` + +* Modify the CSS styling of an existing model by name. + +
+ Sample request: + + ```json + { + "action": "updateModelStyling", + "version": 6, + "params": { + "model": { + "name": "Custom", + "css": "p { color: blue; }" + } + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `findAndReplaceInModels` + +* Find and replace string in existing model by model name. Customise to replace in front, back or css by setting to true/false. + +
+ Sample request: + + ```json + { + "action": "findAndReplaceInModels", + "version": 6, + "params": { + "model": { + "modelName": "", + "findText": "text_to_replace", + "replaceText": "replace_with_text", + "front": true, + "back": true, + "css": true + } + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": 1, + "error": null + } + ``` +
+ +#### `modelTemplateRename` + +* Renames a template in an existing model. + +
+ Sample request: + + ```json + { + "action": "modelTemplateRename", + "version": 6, + "params": { + "modelName": "Basic", + "oldTemplateName": "Card 1", + "newTemplateName": "Card 1 renamed" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelTemplateReposition` + +* Repositions a template in an existing model. + + The value of `index` starts at 0. For example, an index of `0` puts the template in the first position, and an index of `2` puts the template in the third position. + +
+ Sample request: + + ```json + { + "action": "modelTemplateReposition", + "version": 6, + "params": { + "modelName": "Basic", + "templateName": "Card 1", + "index": 1 + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelTemplateAdd` + +* Adds a template to an existing model by name. If you want to update an existing template, use `updateModelTemplates`. + +
+ Sample request: + + ```json + { + "action": "modelTemplateAdd", + "version": 6, + "params": { + "modelName": "Basic", + "template": { + "Name": "Card 3", + "Front": "Front html {{Field1}}", + "Back": "Back html {{Field2}}" + } + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelTemplateRemove` + +* Removes a template from an existing model. + +
+ Sample request: + + ```json + { + "action": "modelTemplateRemove", + "version": 6, + "params": { + "modelName": "Basic", + "templateName": "Card 1" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldRename` + +* Rename the field name of a given model. + +
+ Sample request: + + ```json + { + "action": "modelFieldRename", + "version": 6, + "params": { + "modelName": "Basic", + "oldFieldName": "Front", + "newFieldName": "FrontRenamed" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldReposition` + +* Reposition the field within the field list of a given model. + + The value of `index` starts at 0. For example, an index of `0` puts the field in the first position, and an index of `2` puts the field in the third position. + +
+ Sample request: + + ```json + { + "action": "modelFieldReposition", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "Back", + "index": 0 + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldAdd` + +* Creates a new field within a given model. + + Optionally, the `index` value can be provided, which works exactly the same as the index in `modelFieldReposition`. By default, the field is added to the end of the field list. + +
+ Sample request: + + ```json + { + "action": "modelFieldAdd", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "NewField", + "index": 0 + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldRemove` + +* Deletes a field within a given model. + +
+ Sample request: + + ```json + { + "action": "modelFieldRemove", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "Front" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldSetFont` + +* Sets the font for a field within a given model. + +
+ Sample request: + + ```json + { + "action": "modelFieldSetFont", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "Front", + "font": "Courier" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldSetFontSize` + +* Sets the font size for a field within a given model. + +
+ Sample request: + + ```json + { + "action": "modelFieldSetFontSize", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "Front", + "fontSize": 10 + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `modelFieldSetDescription` + +* Sets the description (the text seen in the gui editor when a field is empty) for a field within a given model. + + Older versions of Anki (2.1.49 and below) do not have field descriptions. In that case, this will return with `false`. + +
+ Sample request: + + ```json + { + "action": "modelFieldSetDescription", + "version": 6, + "params": { + "modelName": "Basic", + "fieldName": "Front", + "description": "example field description" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": true, + "error": null + } + ``` +
+ +--- + +### Note Actions + +#### `addNote` + +* Creates a note using the given deck and model, with the provided field values and tags. Returns the identifier of + the created note created on success, and `null` on failure. + + Anki-Connect can download audio, video, and picture files and embed them in newly created notes. The corresponding `audio`, `video`, and `picture` note members are + optional and can be omitted. If you choose to include any of them, they should contain a single object or an array of objects + with the mandatory `filename` field and one of `data`, `path` or `url`. Refer to the documentation of `storeMediaFile` for an explanation of these fields. + The `skipHash` field can be optionally provided to skip the inclusion of files with an MD5 hash that matches the provided value. + This is useful for avoiding the saving of error pages and stub files. + The `fields` member is a list of fields that should play audio or video, or show a picture when the card is displayed in + Anki. The `allowDuplicate` member inside `options` group can be set to true to enable adding duplicate cards. + Normally duplicate cards can not be added and trigger exception. + + The `duplicateScope` member inside `options` can be used to specify the scope for which duplicates are checked. + A value of `"deck"` will only check for duplicates in the target deck; any other value will check the entire collection. + + The `duplicateScopeOptions` object can be used to specify some additional settings: + + * `duplicateScopeOptions.deckName` will specify which deck to use for checking duplicates in. If undefined or `null`, the target deck will be used. + * `duplicateScopeOptions.checkChildren` will change whether or not duplicate cards are checked in child decks. The default value is `false`. + * `duplicateScopeOptions.checkAllModels` specifies whether duplicate checks are performed across all note types. The default value is `false`. + +
+ Sample request: + + ```json + { + "action": "addNote", + "version": 6, + "params": { + "note": { + "deckName": "Default", + "modelName": "Basic", + "fields": { + "Front": "front content", + "Back": "back content" + }, + "options": { + "allowDuplicate": false, + "duplicateScope": "deck", + "duplicateScopeOptions": { + "deckName": "Default", + "checkChildren": false, + "checkAllModels": false + } + }, + "tags": [ + "yomichan" + ], + "audio": [{ + "url": "https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?kanji=猫&kana=ねこ", + "filename": "yomichan_ねこ_猫.mp3", + "skipHash": "7e2c2f954ef6051373ba916f000168dc", + "fields": [ + "Front" + ] + }], + "video": [{ + "url": "https://cdn.videvo.net/videvo_files/video/free/2015-06/small_watermarked/Contador_Glam_preview.mp4", + "filename": "countdown.mp4", + "skipHash": "4117e8aab0d37534d9c8eac362388bbe", + "fields": [ + "Back" + ] + }], + "picture": [{ + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c7/A_black_cat_named_Tilly.jpg/220px-A_black_cat_named_Tilly.jpg", + "filename": "black_cat.jpg", + "skipHash": "8d6e4646dfae812bf39651b59d7429ce", + "fields": [ + "Back" + ] + }] + } + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": 1496198395707, + "error": null + } + ``` +
+ +#### `addNotes` + +* Creates multiple notes using the given deck and model, with the provided field values and tags. Returns an array of + identifiers of the created notes. In the event of any errors, all errors are gathered and returned. +* Please see the documentation for `addNote` for an explanation of objects in the `notes` array. + +
+ Sample request: + + ```json + { + "action":"addNotes", + "version":6, + "params":{ + "notes":[ + { + "deckName":"College::PluginDev", + "modelName":"non_existent_model", + "fields":{ + "Front":"front", + "Back":"bak" + } + }, + { + "deckName":"College::PluginDev", + "modelName":"Basic", + "fields":{ + "Front":"front", + "Back":"bak" + } + } + ] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result":null, + "error":"['model was not found: non_existent_model']" + } + ``` +
+ +#### `canAddNotes` + +* Accepts an array of objects which define parameters for candidate notes (see `addNote`) and returns an array of + booleans indicating whether or not the parameters at the corresponding index could be used to create a new note. + +
+ Sample request: + + ```json + { + "action": "canAddNotes", + "version": 6, + "params": { + "notes": [ + { + "deckName": "Default", + "modelName": "Basic", + "fields": { + "Front": "front content", + "Back": "back content" + }, + "tags": [ + "yomichan" + ] + } + ] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [true], + "error": null + } + ``` +
+ +#### `canAddNotesWithErrorDetail` + +* Accepts an array of objects which define parameters for candidate notes (see `addNote`) and returns an array of + objects with fields `canAdd` and `error`. + + * `canAdd` indicates whether or not the parameters at the corresponding index could be used to create a new note. + * `error` contains an explanation of why a note cannot be added. + +
+ Sample request: + + ```json + { + "action": "canAddNotesWithErrorDetail", + "version": 6, + "params": { + "notes": [ + { + "deckName": "Default", + "modelName": "Basic", + "fields": { + "Front": "front content", + "Back": "back content" + }, + "tags": [ + "yomichan" + ] + }, + { + "deckName": "Default", + "modelName": "Basic", + "fields": { + "Front": "front content 2", + "Back": "back content 2" + }, + "tags": [ + "yomichan" + ] + } + ] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [ + { + "canAdd": false, + "error": "cannot create note because it is a duplicate" + }, + { + "canAdd": true + } + ], + "error": null + } + ``` +
+ +#### `updateNoteFields` + +* Modify the fields of an existing note. You can also include audio, video, or picture files which will be added to the note with an + optional `audio`, `video`, or `picture` property. Please see the documentation for `addNote` for an explanation of objects in the `audio`, `video`, or `picture` array. + + > **Warning**: + > You must not be viewing the note that you are updating on your Anki browser, otherwise + > the fields will not update. See [this issue](https://github.com/FooSoft/anki-connect/issues/82) + > for further details. + +
+ Sample request: + + ```json + { + "action": "updateNoteFields", + "version": 6, + "params": { + "note": { + "id": 1514547547030, + "fields": { + "Front": "new front content", + "Back": "new back content" + }, + "audio": [{ + "url": "https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?kanji=猫&kana=ねこ", + "filename": "yomichan_ねこ_猫.mp3", + "skipHash": "7e2c2f954ef6051373ba916f000168dc", + "fields": [ + "Front" + ] + }] + } + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `updateNote` + +* Modify the fields and/or tags of an existing note. + In other words, combines `updateNoteFields` and `updateNoteTags`. + Please see their documentation for an explanation of all properties. + + Either `fields` or `tags` property can be omitted without affecting the other. + Thus valid requests to `updateNoteFields` also work with `updateNote`. + The note must have the `fields` property in order to update the optional audio, video, or picture objects. + + If neither `fields` nor `tags` are provided, the method will fail. + Fields are updated first and are not rolled back if updating tags fails. + Tags are not updated if updating fields fails. + + > **Warning** + > You must not be viewing the note that you are updating on your Anki browser, otherwise + > the fields will not update. See [this issue](https://github.com/FooSoft/anki-connect/issues/82) + > for further details. + +
+ Sample request: + + ```json + { + "action": "updateNote", + "version": 6, + "params": { + "note": { + "id": 1514547547030, + "fields": { + "Front": "new front content", + "Back": "new back content" + }, + "tags": ["new", "tags"] + } + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `updateNoteModel` + +* Update the model, fields, and tags of an existing note. + This allows you to change the note's model, update its fields with new content, and set new tags. + +
+ Sample request: + + ```json + { + "action": "updateNoteModel", + "version": 6, + "params": { + "note": { + "id": 1514547547030, + "modelName": "NewModel", + "fields": { + "NewField1": "new field 1", + "NewField2": "new field 2", + "NewField3": "new field 3" + }, + "tags": ["new", "updated", "tags"] + } + } + } + ``` + +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` + +
+ +#### `updateNoteTags` + +* Set a note's tags by note ID. Old tags will be removed. + +
+ Sample request: + + ```json + { + "action": "updateNoteTags", + "version": 6, + "params": { + "note": 1483959289817, + "tags": ["european-languages"] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `getNoteTags` + +* Get a note's tags by note ID. + +
+ Sample request: + + ```json + { + "action": "getNoteTags", + "version": 6, + "params": { + "note": 1483959289817 + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": ["european-languages"], + "error": null + } + ``` +
+ +#### `addTags` + +* Adds tags to notes by note ID. + +
+ Sample request: + + ```json + { + "action": "addTags", + "version": 6, + "params": { + "notes": [1483959289817, 1483959291695], + "tags": "european-languages" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `removeTags` + +* Remove tags from notes by note ID. + +
+ Sample request: + + ```json + { + "action": "removeTags", + "version": 6, + "params": { + "notes": [1483959289817, 1483959291695], + "tags": "european-languages" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `getTags` + +* Gets the complete list of tags for the current user. + +
+ Sample request: + + ```json + { + "action": "getTags", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": ["european-languages", "idioms"], + "error": null + } + ``` +
+ +#### `clearUnusedTags` + +* Clears all the unused tags in the notes for the current user. + +
+ Sample request: + + ```json + { + "action": "clearUnusedTags", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `replaceTags` + +* Replace tags in notes by note ID. + +
+ Sample request: + + ```json + { + "action": "replaceTags", + "version": 6, + "params": { + "notes": [1483959289817, 1483959291695], + "tag_to_replace": "european-languages", + "replace_with_tag": "french-languages" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `replaceTagsInAllNotes` + +* Replace tags in all the notes for the current user. + +
+ Sample request: + + ```json + { + "action": "replaceTagsInAllNotes", + "version": 6, + "params": { + "tag_to_replace": "european-languages", + "replace_with_tag": "french-languages" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `findNotes` + +* Returns an array of note IDs for a given query. Query syntax is [documented here](https://docs.ankiweb.net/searching.html). + +
+ Sample request: + + ```json + { + "action": "findNotes", + "version": 6, + "params": { + "query": "deck:current" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [1483959289817, 1483959291695], + "error": null + } + ``` +
+ +#### `notesInfo` + +* Returns a list of objects containing for each note ID the note fields, tags, note type, modification time,the cards belonging to + the note and the profile where the note was created. + +
+ Sample request (note ids): + + ```json + { + "action": "notesInfo", + "version": 6, + "params": { + "notes": [1502298033753] + } + } + ``` +
+ +
+ Sample request (query): + + ```json + { + "action": "notesInfo", + "version": 6, + "params": { + "query": "deck:current" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [ + { + "noteId":1502298033753, + "profile": "User_1", + "modelName": "Basic", + "tags":["tag","another_tag"], + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} + }, + "mod": 1718377864, + "cards": [1498938915662] + } + ], + "error": null + } + ``` +
+s +#### `notesModTime` + +* Returns a list of objects containings for each note ID the modification time. + +
+ Sample request: + + ```json + { + "action": "notesModTime", + "version": 6, + "params": { + "notes": [1502298033753] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [ + { + "noteId": 1498938915662, + "mod": 1629454092 + } + ], + "error": null + } + ``` +
+ + +#### `deleteNotes` + +* Deletes notes with the given ids. If a note has several cards associated with it, all associated cards will be deleted. + +
+ Sample request: + + ```json + { + "action": "deleteNotes", + "version": 6, + "params": { + "notes": [1502298033753] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +#### `removeEmptyNotes` + +* Removes all the empty notes for the current user. + +
+ Sample request: + + ```json + { + "action": "removeEmptyNotes", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
+ +--- + +### Statistic Actions + +#### `getNumCardsReviewedToday` + +* Gets the count of cards that have been reviewed in the current day (with day start time as configured by user in anki) + +
+ Sample request: + + ```json + { + "action": "getNumCardsReviewedToday", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": 0, + "error": null + } + ``` +
+ +#### `getNumCardsReviewedByDay` + +* Gets the number of cards reviewed as a list of pairs of `(dateString, number)` + +
+ Sample request: + + ```json + { + "action": "getNumCardsReviewedByDay", + "version": 6 + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [ + ["2021-02-28", 124], + ["2021-02-27", 261] + ], + "error": null + } + ``` +
+ +#### `getCollectionStatsHTML` + +* Gets the collection statistics report + +
+ Sample request: + + ```json + { + "action": "getCollectionStatsHTML", + "version": 6, + "params": { + "wholeCollection": true + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": "
lots of HTML here
", + "error": null + } + ``` +
+ +#### `cardReviews` + +* Requests all card reviews for a specified deck after a certain time. + `startID` is the latest unix time not included in the result. + Returns a list of 9-tuples `(reviewTime, cardID, usn, buttonPressed, newInterval, previousInterval, newFactor, reviewDuration, reviewType)` + +
+ Sample request: + + ```json + { + "action": "cardReviews", + "version": 6, + "params": { + "deck": "default", + "startID": 1594194095740 + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": [ + [1594194095746, 1485369733217, -1, 3, 4, -60, 2500, 6157, 0], + [1594201393292, 1485369902086, -1, 1, -60, -60, 0, 4846, 0] + ], + "error": null + } + ``` +
+ +#### `getReviewsOfCards` + +* Requests all card reviews for each card ID. + Returns a dictionary mapping each card ID to a list of dictionaries of the format: + ``` + { + "id": reviewTime, + "usn": usn, + "ease": buttonPressed, + "ivl": newInterval, + "lastIvl": previousInterval, + "factor": newFactor, + "time": reviewDuration, + "type": reviewType, + } + ``` + The reason why these key values are used instead of the more descriptive counterparts + is because these are the exact key values used in Anki's database. + +
+ Sample request: + + ```json + { + "action": "getReviewsOfCards", + "version": 6, + "params": { + "cards": [ + "1653613948202" + ] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": { + "1653613948202": [ + { + "id": 1653772912146, + "usn": 1750, + "ease": 1, + "ivl": -20, + "lastIvl": -20, + "factor": 0, + "time": 38192, + "type": 0 + }, + { + "id": 1653772965429, + "usn": 1750, + "ease": 3, + "ivl": -45, + "lastIvl": -20, + "factor": 0, + "time": 15337, + "type": 0 + } + ] + }, + "error": null + } + ``` +
+ +#### `getLatestReviewID` + +* Returns the unix time of the latest review for the given deck. 0 if no review has ever been made for the deck. + +
+ Sample request: + + ```json + { + "action": "getLatestReviewID", + "version": 6, + "params": { + "deck": "default" + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": 1594194095746, + "error": null + } + ``` +
+ +#### `insertReviews` + +* Inserts the given reviews into the database. Required format: list of 9-tuples `(reviewTime, cardID, usn, buttonPressed, newInterval, previousInterval, newFactor, reviewDuration, reviewType)` + +
+ Sample request: + + ```json + { + "action": "insertReviews", + "version": 6, + "params": { + "reviews": [ + [1594194095746, 1485369733217, -1, 3, 4, -60, 2500, 6157, 0], + [1594201393292, 1485369902086, -1, 1, -60, -60, 0, 4846, 0] + ] + } + } + ``` +
+ +
+ Sample result: + + ```json + { + "result": null, + "error": null + } + ``` +
diff --git a/link.sh b/link.sh new file mode 100755 index 0000000..7e7fe3c --- /dev/null +++ b/link.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +plugin_name=AnkiConnectDev +plugin_path_linux=~/.local/share/Anki2/addons21 +plugin_path_mac=~/Library/Application\ Support/Anki2/addons21 + +if [ -d "$plugin_path_linux" ]; then + ln -s -f $(pwd)/plugin $plugin_path_linux/$plugin_name +fi + +if [ -d "$plugin_path_mac" ]; then + ln -s -f $(pwd)/plugin $plugin_path_mac/$plugin_name +fi diff --git a/package.sh b/package.sh new file mode 100755 index 0000000..2d86b79 --- /dev/null +++ b/package.sh @@ -0,0 +1,3 @@ +#!/usr/bin/bash +git clean -xdf +7za a AnkiConnect.zip ./plugin/* diff --git a/plugin/__init__.py b/plugin/__init__.py new file mode 100644 index 0000000..79c22d6 --- /dev/null +++ b/plugin/__init__.py @@ -0,0 +1,2177 @@ +# Copyright 2016-2021 Alex Yatskov +# +# 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 . + +import aqt + +required_anki_version = (23, 10, 0) +anki_version = tuple(int(segment) for segment in aqt.appVersion.split(".")) + +if anki_version < required_anki_version: + raise Exception(f"Minimum Anki version supported: {required_anki_version[0]}.{required_anki_version[1]}.{required_anki_version[2]}") + +import base64 +import glob +import hashlib +import inspect +import json +import os +import os.path +import platform +import re +import time +import unicodedata + +import anki +import anki.exporting +import anki.storage +from anki.cards import Card +from anki.consts import MODEL_CLOZE +from anki.exporting import AnkiPackageExporter +from anki.importing import AnkiPackageImporter +from anki.notes import Note +from anki.errors import NotFoundError +from anki.scheduler.base import ScheduleCardsAsNew +from aqt.qt import Qt, QTimer, QMessageBox, QCheckBox + +from .web import format_exception_reply, format_success_reply +from .edit import Edit +from . import web, util + + +# +# AnkiConnect +# + +class AnkiConnect: + def __init__(self): + self.log = None + self.timer = None + self.server = web.WebServer(self.handler) + + def initLogging(self): + logPath = util.setting('apiLogPath') + if logPath is not None: + self.log = open(logPath, 'w') + + def startWebServer(self): + try: + self.server.listen() + + # only keep reference to prevent garbage collection + self.timer = QTimer() + self.timer.timeout.connect(self.advance) + self.timer.start(util.setting('apiPollInterval')) + except: + QMessageBox.critical( + self.window(), + 'AnkiConnect', + 'Failed to listen on port {}.\nMake sure it is available and is not in use.'.format(util.setting('webBindPort')) + ) + + def save_model(self, models, ankiModel): + models.update_dict(ankiModel) + + def logEvent(self, name, data): + if self.log is not None: + self.log.write('[{}]\n'.format(name)) + json.dump(data, self.log, indent=4, sort_keys=True) + self.log.write('\n\n') + self.log.flush() + + + def advance(self): + self.server.advance() + + + def handler(self, request): + self.logEvent('request', request) + + name = request.get('action', '') + version = request.get('version', 4) + params = request.get('params', {}) + key = request.get('key') + + try: + if key != util.setting('apiKey') and name != 'requestPermission': + raise Exception('valid api key must be provided') + + 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') + + api_return_value = methodInst(**params) + reply = format_success_reply(version, api_return_value) + + except Exception as e: + reply = format_exception_reply(version, e) + + self.logEvent('reply', reply) + return reply + + + def window(self): + return aqt.mw + + + def reviewer(self): + reviewer = self.window().reviewer + if reviewer is None: + raise Exception('reviewer is not available') + + return reviewer + + + def collection(self): + collection = self.window().col + if collection is None: + raise Exception('collection is not available') + + return collection + + + def decks(self): + decks = self.collection().decks + if decks is None: + raise Exception('decks are not available') + + return decks + + + def scheduler(self): + scheduler = self.collection().sched + if scheduler is None: + raise Exception('scheduler is not available') + + return scheduler + + + def database(self): + database = self.collection().db + if database is None: + raise Exception('database is not available') + + return database + + + def media(self): + media = self.collection().media + if media is None: + raise Exception('media is not available') + + return media + + + def getModel(self, modelName): + model = self.collection().models.by_name(modelName) + if model is None: + raise Exception('model was not found: {}'.format(modelName)) + return model + + + def getField(self, model, fieldName): + fieldMap = self.collection().models.field_map(model) + if fieldName not in fieldMap: + raise Exception('field was not found in {}: {}'.format(model['name'], fieldName)) + return fieldMap[fieldName][1] + + + def getTemplate(self, model, templateName): + for ankiTemplate in model['tmpls']: + if ankiTemplate['name'] == templateName: + return ankiTemplate + raise Exception('template was not found in {}: {}'.format(model['name'], templateName)) + + + def startEditing(self): + self.window().requireReset() + + + def createNote(self, note): + collection = self.collection() + + model = collection.models.by_name(note['modelName']) + if model is None: + raise Exception('model was not found: {}'.format(note['modelName'])) + + deck = collection.decks.by_name(note['deckName']) + if deck is None: + raise Exception('deck was not found: {}'.format(note['deckName'])) + + ankiNote = anki.notes.Note(collection, model) + ankiNote.note_type()['did'] = deck['id'] + if 'tags' in note: + ankiNote.tags = note['tags'] + + for name, value in note['fields'].items(): + for ankiName in ankiNote.keys(): + if name.lower() == ankiName.lower(): + ankiNote[ankiName] = value + break + + self.addMediaFromNote(ankiNote, note) + + allowDuplicate = False + duplicateScope = None + duplicateScopeDeckName = None + duplicateScopeCheckChildren = False + duplicateScopeCheckAllModels = False + + if 'options' in note: + options = note['options'] + if 'allowDuplicate' in options: + allowDuplicate = options['allowDuplicate'] + if type(allowDuplicate) is not bool: + raise Exception('option parameter "allowDuplicate" must be boolean') + if 'duplicateScope' in options: + duplicateScope = options['duplicateScope'] + if 'duplicateScopeOptions' in options: + duplicateScopeOptions = options['duplicateScopeOptions'] + if 'deckName' in duplicateScopeOptions: + duplicateScopeDeckName = duplicateScopeOptions['deckName'] + if 'checkChildren' in duplicateScopeOptions: + duplicateScopeCheckChildren = duplicateScopeOptions['checkChildren'] + if type(duplicateScopeCheckChildren) is not bool: + raise Exception('option parameter "duplicateScopeOptions.checkChildren" must be boolean') + if 'checkAllModels' in duplicateScopeOptions: + duplicateScopeCheckAllModels = duplicateScopeOptions['checkAllModels'] + if type(duplicateScopeCheckAllModels) is not bool: + raise Exception('option parameter "duplicateScopeOptions.checkAllModels" must be boolean') + + duplicateOrEmpty = self.isNoteDuplicateOrEmptyInScope( + ankiNote, + deck, + collection, + duplicateScope, + duplicateScopeDeckName, + duplicateScopeCheckChildren, + duplicateScopeCheckAllModels + ) + + if duplicateOrEmpty == 1: + raise Exception('cannot create note because it is empty') + elif duplicateOrEmpty == 2: + if allowDuplicate: + return ankiNote + raise Exception('cannot create note because it is a duplicate') + elif duplicateOrEmpty == 0: + return ankiNote + else: + raise Exception('cannot create note for unknown reason') + + + def isNoteDuplicateOrEmptyInScope( + self, + note, + deck, + collection, + duplicateScope, + duplicateScopeDeckName, + duplicateScopeCheckChildren, + duplicateScopeCheckAllModels + ): + # Returns: 1 if first is empty, 2 if first is a duplicate, 0 otherwise. + + # note.dupeOrEmpty returns if a note is a global duplicate with the specific model. + # This is used as the default check, and the rest of this function is manually + # checking if the note is a duplicate with additional options. + if duplicateScope != 'deck' and not duplicateScopeCheckAllModels: + return note.dupeOrEmpty() or 0 + + # Primary field for uniqueness + val = note.fields[0] + if not val.strip(): + return 1 + csum = anki.utils.field_checksum(val) + + # Create dictionary of deck ids + dids = None + if duplicateScope == 'deck': + did = deck['id'] + if duplicateScopeDeckName is not None: + deck2 = collection.decks.by_name(duplicateScopeDeckName) + if deck2 is None: + # Invalid deck, so cannot be duplicate + return 0 + did = deck2['id'] + + dids = {did: True} + if duplicateScopeCheckChildren: + for kv in collection.decks.children(did): + dids[kv[1]] = True + + # Build query + query = 'select id from notes where csum=?' + queryArgs = [csum] + if note.id: + query += ' and id!=?' + queryArgs.append(note.id) + if not duplicateScopeCheckAllModels: + query += ' and mid=?' + queryArgs.append(note.mid) + + # Search + for noteId in note.col.db.list(query, *queryArgs): + if dids is None: + # Duplicate note exists in the collection + return 2 + # Validate that a card exists in one of the specified decks + for cardDeckId in note.col.db.list('select did from cards where nid=?', noteId): + if cardDeckId in dids: + return 2 + + # Not a duplicate + return 0 + + def raiseNotFoundError(self, errorMsg): + if anki_version < (2, 1, 55): + raise NotFoundError(errorMsg) + raise NotFoundError(errorMsg, None, None, None) + + def getCard(self, card_id: int) -> Card: + try: + return self.collection().get_card(card_id) + except NotFoundError: + self.raiseNotFoundError('Card was not found: {}'.format(card_id)) + + def getNote(self, note_id: int) -> Note: + try: + return self.collection().get_note(note_id) + except NotFoundError: + self.raiseNotFoundError('Note was not found: {}'.format(note_id)) + + def deckStatsToJson(self, due_tree): + deckStats = {'deck_id': due_tree.deck_id, + 'name': due_tree.name, + 'new_count': due_tree.new_count, + 'learn_count': due_tree.learn_count, + 'review_count': due_tree.review_count} + if anki_version > (2, 1, 46): + # total_in_deck is not supported on lower Anki versions + deckStats['total_in_deck'] = due_tree.total_in_deck + return deckStats + + def collectDeckTreeChildren(self, parent_node): + allNodes = {parent_node.deck_id: parent_node} + for child in parent_node.children: + for deckId, childNode in self.collectDeckTreeChildren(child).items(): + allNodes[deckId] = childNode + return allNodes + + # + # Miscellaneous + # + + @util.api() + def version(self): + return util.setting('apiVersion') + + + @util.api() + def requestPermission(self, origin, allowed): + results = { + "permission": "denied", + } + + if allowed: + results = { + "permission": "granted", + "requireApikey": bool(util.setting('apiKey')), + "version": util.setting('apiVersion') + } + + elif origin in util.setting('ignoreOriginList'): + pass # defaults to denied + + else: # prompt the user + msg = QMessageBox(None) + msg.setWindowTitle("A website wants to access to Anki") + msg.setText('"{}" requests permission to use Anki through AnkiConnect. Do you want to give it access?'.format(origin)) + msg.setInformativeText("By granting permission, you'll allow the website to modify your collection on your behalf, including the execution of destructive actions such as deck deletion.") + msg.setWindowIcon(self.window().windowIcon()) + msg.setIcon(QMessageBox.Icon.Question) + msg.setStandardButtons(QMessageBox.StandardButton.Yes|QMessageBox.StandardButton.No) + msg.setDefaultButton(QMessageBox.StandardButton.No) + msg.setCheckBox(QCheckBox(text='Ignore further requests from "{}"'.format(origin), parent=msg)) + if hasattr(Qt, 'WindowStaysOnTopHint'): + # Qt5 + WindowOnTopFlag = Qt.WindowStaysOnTopHint + elif hasattr(Qt, 'WindowType') and hasattr(Qt.WindowType, 'WindowStaysOnTopHint'): + # Qt6 + WindowOnTopFlag = Qt.WindowType.WindowStaysOnTopHint + msg.setWindowFlags(WindowOnTopFlag) + pressedButton = msg.exec() + + if pressedButton == QMessageBox.StandardButton.Yes: + config = aqt.mw.addonManager.getConfig(__name__) + config["webCorsOriginList"] = util.setting('webCorsOriginList') + config["webCorsOriginList"].append(origin) + aqt.mw.addonManager.writeConfig(__name__, config) + results = { + "permission": "granted", + "requireApikey": bool(util.setting('apiKey')), + "version": util.setting('apiVersion') + } + + # if the origin isn't an empty string, the user clicks "No", and the ignore box is checked + elif origin and pressedButton == QMessageBox.StandardButton.No and msg.checkBox().isChecked(): + config = aqt.mw.addonManager.getConfig(__name__) + config["ignoreOriginList"] = util.setting('ignoreOriginList') + config["ignoreOriginList"].append(origin) + aqt.mw.addonManager.writeConfig(__name__, config) + + # else defaults to denied + + return results + + + @util.api() + def getProfiles(self): + return self.window().pm.profiles() + + @util.api() + def getActiveProfile(self): + return self.window().pm.name + + @util.api() + def loadProfile(self, name): + if name not in self.window().pm.profiles(): + return False + + if self.window().isVisible(): + cur_profile = self.window().pm.name + if cur_profile != name: + self.window().unloadProfileAndShowProfileManager() + + def waiter(): + # This function waits until main window is closed + # It's needed cause sync can take quite some time + # And if we call loadProfile until sync is ended things will go wrong + if self.window().isVisible(): + QTimer.singleShot(1000, waiter) + else: + self.loadProfile(name) + + waiter() + else: + self.window().pm.load(name) + self.window().loadProfile() + self.window().profileDiag.closeWithoutQuitting() + + return True + + + @util.api() + def sync(self): + mw = self.window() + auth = mw.pm.sync_auth() + if not auth: + raise Exception("sync: auth not configured") + out = mw.col.sync_collection(auth, mw.pm.media_syncing_enabled()) + accepted_sync_statuses = [out.NO_CHANGES, out.NORMAL_SYNC] + if out.required not in accepted_sync_statuses: + raise Exception(f"Sync status {out.required} not one of {accepted_sync_statuses} - see SyncCollectionResponse.ChangesRequired for list of sync statuses: https://github.com/ankitects/anki/blob/e41c4573d789afe8b020fab5d9d1eede50c3fa3d/proto/anki/sync.proto#L57-L65") + mw.onSync() + + + @util.api() + def multi(self, actions): + return list(map(self.handler, actions)) + + + @util.api() + def getNumCardsReviewedToday(self): + return self.database().scalar('select count() from revlog where id > ?', (self.scheduler().dayCutoff - 86400) * 1000) + + @util.api() + def getNumCardsReviewedByDay(self): + return self.database().all('select date(id/1000 - ?, "unixepoch", "localtime") as day, count() from revlog group by day order by day desc', + int(time.strftime("%H", time.localtime(self.scheduler().dayCutoff))) * 3600) + + + @util.api() + def getCollectionStatsHTML(self, wholeCollection=True): + stats = self.collection().stats() + stats.wholeCollection = wholeCollection + return stats.report() + + + # + # Decks + # + + @util.api() + def deckNames(self): + return [x.name for x in self.decks().all_names_and_ids()] + + + @util.api() + def deckNamesAndIds(self): + decks = {} + for deck in self.deckNames(): + decks[deck] = self.decks().id(deck) + + return decks + + + @util.api() + def getDecks(self, cards): + decks = {} + for card in cards: + 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: + decks[deck] = [card] + + return decks + + + @util.api() + def createDeck(self, deck): + self.startEditing() + return self.decks().id(deck) + + + @util.api() + def changeDeck(self, cards, deck): + self.startEditing() + + did = self.collection().decks.id(deck) + mod = anki.utils.int_time() + 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) + + + @util.api() + def deleteDecks(self, decks, cardsToo=False): + if not cardsToo: + # since f592672fa952260655881a75a2e3c921b2e23857 (2.1.28) + # (see anki$ git log "-Gassert cardsToo") + # you can't delete decks without deleting cards as well. + # however, since 62c23c6816adf912776b9378c008a52bb50b2e8d (2.1.45) + # passing cardsToo to `rem` (long deprecated) won't raise an error! + # this is dangerous, so let's raise our own exception + raise Exception("Since Anki 2.1.28 it's not possible " + "to delete decks without deleting cards as well") + self.startEditing() + decks = filter(lambda d: d in self.deckNames(), decks) + for deck in decks: + did = self.decks().id(deck) + self.decks().remove([did]) + + + @util.api() + def getDeckConfig(self, deck): + if deck not in self.deckNames(): + return False + + collection = self.collection() + did = collection.decks.id(deck) + return collection.decks.config_dict_for_deck_id(did) + + + @util.api() + def saveDeckConfig(self, config): + collection = self.collection() + + config['id'] = str(config['id']) + config['mod'] = anki.utils.int_time() + config['usn'] = collection.usn() + if int(config['id']) not in [c['id'] for c in collection.decks.all_config()]: + return False + try: + collection.decks.save(config) + collection.decks.update_config(config) + except: + return False + return True + + + @util.api() + def setDeckConfigId(self, decks, configId): + configId = int(configId) + for deck in decks: + if not deck in self.deckNames(): + return False + + collection = self.collection() + + for deck in decks: + try: + did = str(collection.decks.id(deck)) + deck_dict = aqt.mw.col.decks.decks[did] + deck_dict['conf'] = configId + collection.decks.save(deck_dict) + except: + return False + + return True + + + @util.api() + def cloneDeckConfigId(self, name, cloneFrom='1'): + configId = int(cloneFrom) + collection = self.collection() + if configId not in [c['id'] for c in collection.decks.all_config()]: + return False + + config = collection.decks.get_config(configId) + return collection.decks.add_config_returning_id(name, config) + + + @util.api() + def removeDeckConfigId(self, configId): + collection = self.collection() + if int(configId) not in [c['id'] for c in collection.decks.all_config()]: + return False + + collection.decks.remove_config(configId) + return True + + @util.api() + def getDeckStats(self, decks): + collection = self.collection() + scheduler = self.scheduler() + responseDict = {} + deckIds = list(map(lambda d: collection.decks.id(d), decks)) + + allDeckNodes = self.collectDeckTreeChildren(scheduler.deck_due_tree()) + for deckId, deckNode in allDeckNodes.items(): + if deckId in deckIds: + responseDict[deckId] = self.deckStatsToJson(deckNode) + return responseDict + + @util.api() + def storeMediaFile(self, filename, data=None, path=None, url=None, skipHash=None, deleteExisting=True): + if not (data or path or url): + raise Exception('You must provide a "data", "path", or "url" field.') + if data: + mediaData = base64.b64decode(data) + elif path: + with open(path, 'rb') as f: + mediaData = f.read() + elif url: + mediaData = util.download(url) + + if skipHash is None: + skip = False + else: + m = hashlib.md5() + m.update(mediaData) + skip = skipHash == m.hexdigest() + + if skip: + return None + if deleteExisting: + self.deleteMediaFile(filename) + return self.media().writeData(filename, mediaData) + + + @util.api() + def retrieveMediaFile(self, filename): + filename = os.path.basename(filename) + filename = unicodedata.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 + + + @util.api() + def getMediaFilesNames(self, pattern='*'): + path = os.path.join(self.media().dir(), pattern) + return [os.path.basename(p) for p in glob.glob(path)] + + + @util.api() + def deleteMediaFile(self, filename): + self.media().trash_files([filename]) + + @util.api() + def getMediaDirPath(self): + return os.path.abspath(self.media().dir()) + + @util.api() + def addNote(self, note): + ankiNote = self.createNote(note) + + collection = self.collection() + 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.') + + return ankiNote.id + + + def addMediaFromNote(self, ankiNote, note): + audioObjectOrList = note.get('audio') + self.addMedia(ankiNote, audioObjectOrList, util.MediaType.Audio) + + videoObjectOrList = note.get('video') + self.addMedia(ankiNote, videoObjectOrList, util.MediaType.Video) + + pictureObjectOrList = note.get('picture') + self.addMedia(ankiNote, pictureObjectOrList, util.MediaType.Picture) + + + + def addMedia(self, ankiNote, mediaObjectOrList, mediaType): + if mediaObjectOrList is None: + return + + if isinstance(mediaObjectOrList, list): + mediaList = mediaObjectOrList + else: + mediaList = [mediaObjectOrList] + + for media in mediaList: + if media is not None and len(media['fields']) > 0: + try: + mediaFilename = self.storeMediaFile(media['filename'], + data=media.get('data'), + path=media.get('path'), + url=media.get('url'), + skipHash=media.get('skipHash'), + deleteExisting=media.get('deleteExisting')) + + if mediaFilename is not None: + for field in media['fields']: + if field in ankiNote: + if mediaType is util.MediaType.Picture: + ankiNote[field] += u''.format(mediaFilename) + elif mediaType is util.MediaType.Audio or mediaType is util.MediaType.Video: + ankiNote[field] += u'[sound:{}]'.format(mediaFilename) + + except Exception as e: + errorMessage = str(e).replace('&', '&').replace('<', '<').replace('>', '>') + for field in media['fields']: + if field in ankiNote: + ankiNote[field] += errorMessage + + + @util.api() + def canAddNote(self, note): + try: + return bool(self.createNote(note)) + except: + return False + + @util.api() + def canAddNoteWithErrorDetail(self, note): + try: + return { + 'canAdd': bool(self.createNote(note)) + } + except Exception as e: + return { + 'canAdd': False, + 'error': str(e) + } + + @util.api() + def updateNoteFields(self, note): + ankiNote = self.getNote(note['id']) + + self.startEditing() + for name, value in note['fields'].items(): + if name in ankiNote: + ankiNote[name] = value + + self.addMediaFromNote(ankiNote, note) + + self.collection().update_note(ankiNote, skip_undo_entry=True); + + + @util.api() + def updateNote(self, note): + updated = False + if 'fields' in note.keys(): + self.updateNoteFields(note) + updated = True + if 'tags' in note.keys(): + self.updateNoteTags(note['id'], note['tags']) + updated = True + if not updated: + raise Exception('Must provide a "fields" or "tags" property.') + + @util.api() + def updateNoteModel(self, note): + """ + Update the model and fields of a given note. + + :param note: A dictionary containing note details, including 'id', 'modelName', 'fields', and 'tags'. + """ + # Extract and validate the note ID + note_id = note.get('id') + if not note_id: + raise ValueError("Note ID is required") + + # Extract and validate the new model name + new_model_name = note.get('modelName') + if not new_model_name: + raise ValueError("Model name is required") + + # Extract and validate the new fields + new_fields = note.get('fields') + if not new_fields or not isinstance(new_fields, dict): + raise ValueError("Fields must be provided as a dictionary") + + # Extract the new tags + new_tags = note.get('tags', []) + + # Get the current note from the collection + anki_note = self.getNote(note_id) + + # Get the new model from the collection + collection = self.collection() + new_model = collection.models.by_name(new_model_name) + if not new_model: + raise ValueError(f"Model '{new_model_name}' not found") + + # Update the note's model + anki_note.mid = new_model['id'] + anki_note._fmap = collection.models.field_map(new_model) + anki_note.fields = [''] * len(new_model['flds']) + + # Update the fields with new values + for name, value in new_fields.items(): + for anki_name in anki_note.keys(): + if name.lower() == anki_name.lower(): + anki_note[anki_name] = value + break + + # Update the tags + anki_note.tags = new_tags + + # Update note to ensure changes are saved + collection.update_note(anki_note, skip_undo_entry=True); + + @util.api() + def updateNoteTags(self, note, tags): + if type(tags) == str: + tags = [tags] + if type(tags) != list or not all([type(t) == str for t in tags]): + raise Exception('Must provide tags as a list of strings') + + for old_tag in self.getNoteTags(note): + self.removeTags([note], old_tag) + for new_tag in tags: + self.addTags([note], new_tag) + + + @util.api() + def getNoteTags(self, note): + return self.getNote(note).tags + + + @util.api() + def addTags(self, notes, tags, add=True): + self.startEditing() + self.collection().tags.bulkAdd(notes, tags, add) + + + @util.api() + def removeTags(self, notes, tags): + return self.addTags(notes, tags, False) + + + @util.api() + def getTags(self): + return self.collection().tags.all() + + + @util.api() + def clearUnusedTags(self): + self.collection().tags.registerNotes() + + + @util.api() + def replaceTags(self, notes, tag_to_replace, replace_with_tag): + self.window().progress.start() + + for nid in notes: + try: + note = self.getNote(nid) + except NotFoundError: + continue + + if note.has_tag(tag_to_replace): + note.remove_tag(tag_to_replace) + note.add_tag(replace_with_tag) + self.collection().update_note(note, skip_undo_entry=True); + + self.window().requireReset() + self.window().progress.finish() + self.window().reset() + + + @util.api() + def replaceTagsInAllNotes(self, tag_to_replace, replace_with_tag): + self.window().progress.start() + + collection = self.collection() + for nid in collection.db.list('select id from notes'): + note = self.getNote(nid) + if note.has_tag(tag_to_replace): + note.remove_tag(tag_to_replace) + note.add_tag(replace_with_tag) + self.collection().update_note(note, skip_undo_entry=True); + + self.window().requireReset() + self.window().progress.finish() + self.window().reset() + + + @util.api() + def setEaseFactors(self, cards, easeFactors): + couldSetEaseFactors = [] + for i, card in enumerate(cards): + try: + ankiCard = self.getCard(card) + except NotFoundError: + couldSetEaseFactors.append(False) + continue + + couldSetEaseFactors.append(True) + ankiCard.factor = easeFactors[i] + self.collection().update_card(ankiCard, skip_undo_entry=True) + + return couldSetEaseFactors + + @util.api() + def setSpecificValueOfCard(self, card, keys, + newValues, warning_check=False): + if isinstance(card, list): + print("card has to be int, not list") + return False + + if not isinstance(keys, list) or not isinstance(newValues, list): + print("keys and newValues have to be lists.") + return False + + if len(newValues) != len(keys): + print("Invalid list lengths.") + return False + + for key in keys: + if key in ["did", "id", "ivl", "lapses", "left", "mod", "nid", + "odid", "odue", "ord", "queue", "reps", "type", "usn"]: + if warning_check is False: + return False + + result = [] + try: + ankiCard = self.getCard(card) + for i, key in enumerate(keys): + setattr(ankiCard, key, newValues[i]) + self.collection().update_card(ankiCard, skip_undo_entry=True) + result.append(True) + except Exception as e: + result.append([False, str(e)]) + return result + + + @util.api() + def getEaseFactors(self, cards): + easeFactors = [] + for card in cards: + try: + ankiCard = self.getCard(card) + except NotFoundError: + easeFactors.append(None) + continue + + easeFactors.append(ankiCard.factor) + + return easeFactors + + + @util.api() + def suspend(self, cards, suspend=True): + for card in cards: + if self.suspended(card) == suspend: + cards.remove(card) + + if len(cards) == 0: + return False + + scheduler = self.scheduler() + self.startEditing() + if suspend: + scheduler.suspendCards(cards) + else: + scheduler.unsuspendCards(cards) + + return True + + + @util.api() + def unsuspend(self, cards): + self.suspend(cards, False) + + + @util.api() + def suspended(self, card): + card = self.getCard(card) + return card.queue == -1 + + + @util.api() + def areSuspended(self, cards): + suspended = [] + for card in cards: + try: + suspended.append(self.suspended(card)) + except NotFoundError: + suspended.append(None) + + return suspended + + + @util.api() + def areDue(self, cards): + due = [] + for card in cards: + if self.findCards('cid:{} is:new'.format(card)): + due.append(True) + else: + date, ivl = self.collection().db.all('select id/1000.0, ivl from revlog where cid = ?', card)[-1] + if ivl >= -1200: + due.append(bool(self.findCards('cid:{} is:due'.format(card)))) + else: + due.append(date - ivl <= time.time()) + + return due + + + @util.api() + def getIntervals(self, cards, complete=False): + intervals = [] + for card in cards: + if self.findCards('cid:{} is:new'.format(card)): + intervals.append(0) + else: + interval = self.collection().db.list('select ivl from revlog where cid = ?', card) + if not complete: + interval = interval[-1] + intervals.append(interval) + + return intervals + + + + @util.api() + def modelNames(self): + return [n.name for n in self.collection().models.all_names_and_ids()] + + + @util.api() + def createModel(self, modelName, inOrderFields, cardTemplates, css = None, isCloze = False): + # 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 [n.name for n in self.collection().models.all_names_and_ids()]: + raise Exception('Model name already exists') + + collection = self.collection() + mm = collection.models + + # Generate new Note + m = mm.new(modelName) + if isCloze: + m['type'] = MODEL_CLOZE + + # Create fields and add them to Note + for field in inOrderFields: + fm = mm.new_field(field) + mm.addField(m, fm) + + # 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: + cardName = 'Card ' + str(cardCount) + if 'Name' in card: + cardName = card['Name'] + + t = mm.new_template(cardName) + cardCount += 1 + t['qfmt'] = card['Front'] + t['afmt'] = card['Back'] + mm.addTemplate(m, t) + + mm.add(m) + return m + + + @util.api() + def modelNamesAndIds(self): + models = {} + for model in self.modelNames(): + models[model] = int(self.collection().models.by_name(model)['id']) + + return models + + + @util.api() + def findModelsById(self, modelIds): + models = [] + for id in modelIds: + model = self.collection().models.get(id) + if model is None: + raise Exception("model was not found: {}".format(id)) + else: + models.append(model) + return models + + @util.api() + def findModelsByName(self, modelNames): + models = [] + for name in modelNames: + model = self.collection().models.by_name(name) + if model is None: + raise Exception("model was not found: {}".format(name)) + else: + models.append(model) + return models + + @util.api() + def modelNameFromId(self, modelId): + model = self.collection().models.get(modelId) + if model is None: + raise Exception('model was not found: {}'.format(modelId)) + else: + return model['name'] + + + @util.api() + def modelFieldNames(self, modelName): + model = self.collection().models.by_name(modelName) + if model is None: + raise Exception('model was not found: {}'.format(modelName)) + else: + return [field['name'] for field in model['flds']] + + + @util.api() + def modelFieldDescriptions(self, modelName): + model = self.collection().models.by_name(modelName) + if model is None: + raise Exception('model was not found: {}'.format(modelName)) + else: + try: + return [field['description'] for field in model['flds']] + except KeyError: + # older versions of Anki don't have field descriptions + return ['' for field in model['flds']] + + + @util.api() + def modelFieldFonts(self, modelName): + model = self.getModel(modelName) + + fonts = {} + for field in model['flds']: + + fonts[field['name']] = { + 'font': field['font'], + 'size': field['size'], + } + + return fonts + + + @util.api() + def modelFieldsOnTemplates(self, modelName): + model = self.collection().models.by_name(modelName) + if model is None: + raise Exception('model was not found: {}'.format(modelName)) + + 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 + + + @util.api() + def modelTemplates(self, modelName): + model = self.collection().models.by_name(modelName) + if model is None: + raise Exception('model was not found: {}'.format(modelName)) + + templates = {} + for template in model['tmpls']: + templates[template['name']] = {'Front': template['qfmt'], 'Back': template['afmt']} + + return templates + + + @util.api() + def modelStyling(self, modelName): + model = self.collection().models.by_name(modelName) + if model is None: + raise Exception('model was not found: {}'.format(modelName)) + + return {'css': model['css']} + + + @util.api() + def updateModelTemplates(self, model): + models = self.collection().models + ankiModel = models.by_name(model['name']) + if ankiModel is None: + raise Exception('model was not found: {}'.format(model['name'])) + + templates = model['templates'] + for ankiTemplate in ankiModel['tmpls']: + template = templates.get(ankiTemplate['name']) + if template: + qfmt = template.get('Front') + if qfmt: + ankiTemplate['qfmt'] = qfmt + + afmt = template.get('Back') + if afmt: + ankiTemplate['afmt'] = afmt + + self.save_model(models, ankiModel) + + + @util.api() + def updateModelStyling(self, model): + models = self.collection().models + ankiModel = models.by_name(model['name']) + if ankiModel is None: + raise Exception('model was not found: {}'.format(model['name'])) + + ankiModel['css'] = model['css'] + + self.save_model(models, ankiModel) + + + @util.api() + def findAndReplaceInModels(self, modelName, findText, replaceText, front=True, back=True, css=True): + if not modelName: + ankiModel = self.collection().models.allNames() + else: + model = self.collection().models.by_name(modelName) + if model is None: + raise Exception('model was not found: {}'.format(modelName)) + ankiModel = [modelName] + updatedModels = 0 + for model in ankiModel: + model = self.collection().models.by_name(model) + checkForText = False + if css and findText in model['css']: + checkForText = True + model['css'] = model['css'].replace(findText, replaceText) + for tmpls in model.get('tmpls'): + if front and findText in tmpls['qfmt']: + checkForText = True + tmpls['qfmt'] = tmpls['qfmt'].replace(findText, replaceText) + if back and findText in tmpls['afmt']: + checkForText = True + tmpls['afmt'] = tmpls['afmt'].replace(findText, replaceText) + self.save_model(self.collection().models, model) + if checkForText: + updatedModels += 1 + return updatedModels + + + @util.api() + def modelTemplateRename(self, modelName, oldTemplateName, newTemplateName): + mm = self.collection().models + model = self.getModel(modelName) + ankiTemplate = self.getTemplate(model, oldTemplateName) + + ankiTemplate['name'] = newTemplateName + self.save_model(mm, model) + + + @util.api() + def modelTemplateReposition(self, modelName, templateName, index): + mm = self.collection().models + model = self.getModel(modelName) + ankiTemplate = self.getTemplate(model, templateName) + + mm.reposition_template(model, ankiTemplate, index) + self.save_model(mm, model) + + + @util.api() + def modelTemplateAdd(self, modelName, template): + # "Name", "Front", "Back" borrows from `createModel` + mm = self.collection().models + model = self.getModel(modelName) + name = template['Name'] + qfmt = template['Front'] + afmt = template['Back'] + + # updates the template if it already exists + for ankiTemplate in model['tmpls']: + if ankiTemplate['name'] == name: + ankiTemplate['qfmt'] = qfmt + ankiTemplate['afmt'] = afmt + return + + ankiTemplate = mm.new_template(name) + ankiTemplate['qfmt'] = qfmt + ankiTemplate['afmt'] = afmt + mm.add_template(model, ankiTemplate) + + self.save_model(mm, model) + + + @util.api() + def modelTemplateRemove(self, modelName, templateName): + mm = self.collection().models + model = self.getModel(modelName) + ankiTemplate = self.getTemplate(model, templateName) + + mm.remove_template(model, ankiTemplate) + self.save_model(mm, model) + + + @util.api() + def modelFieldRename(self, modelName, oldFieldName, newFieldName): + mm = self.collection().models + model = self.getModel(modelName) + field = self.getField(model, oldFieldName) + + mm.renameField(model, field, newFieldName) + + self.save_model(mm, model) + + + @util.api() + def modelFieldReposition(self, modelName, fieldName, index): + mm = self.collection().models + model = self.getModel(modelName) + field = self.getField(model, fieldName) + + mm.reposition_field(model, field, index) + + self.save_model(mm, model) + + + @util.api() + def modelFieldAdd(self, modelName, fieldName, index=None): + mm = self.collection().models + model = self.getModel(modelName) + + # only adds the field if it doesn't already exist + fieldMap = mm.field_map(model) + if fieldName not in fieldMap: + field = mm.new_field(fieldName) + mm.addField(model, field) + + # repositions, even if the field already exists + if index is not None: + fieldMap = mm.field_map(model) + newField = fieldMap[fieldName][1] + mm.reposition_field(model, newField, index) + + self.save_model(mm, model) + + + @util.api() + def modelFieldRemove(self, modelName, fieldName): + mm = self.collection().models + model = self.getModel(modelName) + field = self.getField(model, fieldName) + + mm.remove_field(model, field) + + self.save_model(mm, model) + + + @util.api() + def modelFieldSetFont(self, modelName, fieldName, font): + mm = self.collection().models + model = self.getModel(modelName) + field = self.getField(model, fieldName) + + if not isinstance(font, str): + raise Exception('font should be a string: {}'.format(font)) + + field['font'] = font + + self.save_model(mm, model) + + + @util.api() + def modelFieldSetFontSize(self, modelName, fieldName, fontSize): + mm = self.collection().models + model = self.getModel(modelName) + field = self.getField(model, fieldName) + + if not isinstance(fontSize, int): + raise Exception('fontSize should be an integer: {}'.format(fontSize)) + + field['size'] = fontSize + + self.save_model(mm, model) + + + @util.api() + def modelFieldSetDescription(self, modelName, fieldName, description): + mm = self.collection().models + model = self.getModel(modelName) + field = self.getField(model, fieldName) + + if not isinstance(description, str): + raise Exception('description should be a string: {}'.format(description)) + + if 'description' in field: # older versions do not have the 'description' key + field['description'] = description + self.save_model(mm, model) + return True + return False + + + @util.api() + def deckNameFromId(self, deckId): + deck = self.collection().decks.get(deckId) + if deck is None: + raise Exception('deck was not found: {}'.format(deckId)) + + return deck['name'] + + + @util.api() + def findNotes(self, query=None): + if query is None: + return [] + + return list(map(int, self.collection().find_notes(query))) + + + @util.api() + def findCards(self, query=None): + if query is None: + return [] + + return list(map(int, self.collection().find_cards(query))) + + + @util.api() + def cardsInfo(self, cards): + result = [] + for cid in cards: + try: + card = self.getCard(cid) + model = card.note_type() + note = card.note() + fields = {} + for info in model['flds']: + order = info['ord'] + name = info['name'] + fields[name] = {'value': note.fields[order], 'order': order} + states = self.collection()._backend.get_scheduling_states(card.id) + nextReviews = self.collection()._backend.describe_next_states(states) + + result.append({ + 'cardId': card.id, + 'fields': fields, + 'fieldOrder': card.ord, + 'question': util.cardQuestion(card), + 'answer': util.cardAnswer(card), + 'modelName': model['name'], + 'ord': card.ord, + '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, + 'type': card.type, + 'queue': card.queue, + 'due': card.due, + 'reps': card.reps, + 'lapses': card.lapses, + 'left': card.left, + 'mod': card.mod, + 'nextReviews': list(nextReviews), + 'flags': card.flags, + }) + except NotFoundError: + # Anki will give a NotFoundError 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 + + @util.api() + def cardsModTime(self, cards): + result = [] + for cid in cards: + try: + card = self.getCard(cid) + result.append({ + 'cardId': card.id, + 'mod': card.mod, + }) + except NotFoundError: + # Anki will give a NotFoundError 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 + + @util.api() + def forgetCards(self, cards): + self.startEditing() + request = ScheduleCardsAsNew( + card_ids=cards, + log=True, + restore_position=True, + reset_counts=False, + context=None, + ) + self.collection()._backend.schedule_cards_as_new(request) + + @util.api() + def relearnCards(self, cards): + self.startEditing() + scids = anki.utils.ids2str(cards) + self.collection().db.execute('update cards set type=3, queue=1 where id in ' + scids) + + + @util.api() + def answerCards(self, answers): + scheduler = self.scheduler() + success = [] + for answer in answers: + try: + cid = answer['cardId'] + ease = answer['ease'] + card = self.getCard(cid) + card.start_timer() + scheduler.answerCard(card, ease) + success.append(True) + except NotFoundError: + success.append(False) + + return success + + + @util.api() + def cardReviews(self, deck, startID): + return self.database().all( + 'select id, cid, usn, ease, ivl, lastIvl, factor, time, type from revlog ''where id>? and cid in (select id from cards where did=?)', + startID, + self.decks().id(deck) + ) + + + @util.api() + def getReviewsOfCards(self, cards): + COLUMNS = ['cid', 'id', 'usn', 'ease', 'ivl', 'lastIvl', 'factor', 'time', 'type'] + + cid_to_reviews = {} + # 999 is the maximum number of variables sqlite allows + for cid_batch in util.batched(cards, 999): + placeholders = ','.join('?' * len(cid_batch)) + + cid_reviews = self.collection().db.all('select {} from revlog where cid in ({})'.format(', '.join(COLUMNS), placeholders), *cid_batch) + for cid_review in cid_reviews: + cid = cid_review[0] + reviews = cid_to_reviews.get(cid, []) + reviews.append(cid_review[1:]) + cid_to_reviews[cid] = reviews + + result = {} + for card in cards: + result[card] = [dict(zip(COLUMNS[1:], review)) for review in cid_to_reviews.get(card, [])] + + return result + + + @util.api() + def setDueDate(self, cards, days): + self.scheduler().set_due_date(cards, days, config_key=None) + return True + + + @util.api() + def reloadCollection(self): + self.collection().reset() + + + @util.api() + def getLatestReviewID(self, deck): + return self.database().scalar( + 'select max(id) from revlog where cid in (select id from cards where did=?)', + self.decks().id(deck) + ) or 0 + + + @util.api() + def insertReviews(self, reviews): + if len(reviews) > 0: + sql = 'insert into revlog(id,cid,usn,ease,ivl,lastIvl,factor,time,type) values ' + for row in reviews: + sql += '(%s),' % ','.join(map(str, row)) + sql = sql[:-1] + self.database().execute(sql) + + + @util.api() + def notesInfo(self, notes=None, query=None): + if notes is None and query is None: + raise Exception('Must provide either "notes" or a "query"') + + if query is not None: + notes = self.findNotes(query) + + nid_to_card_ids = {} + # 999 is the maximum number of variables sqlite allows + for nid_batch in util.batched(notes, 999): + placeholders = ','.join('?' * len(nid_batch)) + + cid_and_nids = self.collection().db.all('select id, nid from cards where nid in ({}) order by ord'.format(placeholders), *nid_batch) + for cid, nid in cid_and_nids: + card_ids = nid_to_card_ids.get(nid, []) + card_ids.append(cid) + nid_to_card_ids[nid] = card_ids + + result = [] + for nid in notes: + try: + note = self.getNote(nid) + model = note.note_type() + + 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, + 'profile': self.window().pm.name, + 'tags' : note.tags, + 'fields': fields, + 'modelName': model['name'], + 'mod': note.mod, + 'cards': nid_to_card_ids[nid], + }) + except NotFoundError: + # Anki will give a NotFoundError 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 + + @util.api() + def notesModTime(self, notes): + result = [] + for nid in notes: + try: + note = self.getNote(nid) + result.append({ + 'noteId': note.id, + 'mod': note.mod + }) + except NotFoundError: + # Anki will give a NotFoundError 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 + + @util.api() + def deleteNotes(self, notes): + self.collection().remove_notes(notes) + + + @util.api() + def removeEmptyNotes(self): + for model in self.collection().models.all(): + if self.collection().models.use_count(model) == 0: + self.collection().models.remove(model["id"]) + self.window().requireReset() + + + @util.api() + def cardsToNotes(self, cards): + return self.collection().db.list('select distinct nid from cards where id in ' + anki.utils.ids2str(cards)) + + + @util.api() + def guiBrowse(self, query=None, reorderCards=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() + + if reorderCards is not None: + if not isinstance(reorderCards, dict): + raise Exception('reorderCards should be a dict: {}'.format(reorderCards)) + if not ('columnId' in reorderCards and 'order' in reorderCards): + raise Exception('Must provide a "columnId" and a "order" property"') + + cardOrder = reorderCards['order'] + if cardOrder not in ('ascending', 'descending'): + raise Exception('invalid card order: {}'.format(reorderCards['order'])) + + cardOrder = Qt.SortOrder.DescendingOrder if cardOrder == 'descending' else Qt.SortOrder.AscendingOrder + columnId = browser.table._model.active_column_index(reorderCards['columnId']) + if columnId == None: + raise Exception('invalid columnId: {}'.format(reorderCards['columnId'])) + + browser.table._on_sort_column_changed(columnId, cardOrder) + + return self.findCards(query) + + + @util.api() + def guiEditNote(self, note): + Edit.open_dialog_and_show_note_with_id(note) + + @util.api() + def guiSelectNote(self, note): + print('guiSelectNote actually selects card IDs and is deprecated; use guiSelectCard') + return self.guiSelectCard(note) + + @util.api() + def guiSelectCard(self, card): + (creator, instance) = aqt.dialogs._dialogs['Browser'] + if instance is None: + return False + instance.table.clear_selection() + instance.table.select_single_card(card) + return True + + @util.api() + def guiSelectedNotes(self): + (creator, instance) = aqt.dialogs._dialogs['Browser'] + if instance is None: + return [] + return instance.selectedNotes() + + @util.api() + def guiAddCards(self, note=None): + if note is not None: + collection = self.collection() + + deck = collection.decks.by_name(note['deckName']) + if deck is None: + raise Exception('deck was not found: {}'.format(note['deckName'])) + + collection.decks.select(deck['id']) + savedMid = deck.pop('mid', None) + + model = collection.models.by_name(note['modelName']) + if model is None: + raise Exception('model was not found: {}'.format(note['modelName'])) + + collection.models.set_current(model) + collection.models.update(model) + + ankiNote = anki.notes.Note(collection, model) + + # fill out card beforehand, so we can be sure of the note id + if 'fields' in note: + for name, value in note['fields'].items(): + if name in ankiNote: + ankiNote[name] = value + + self.addMediaFromNote(ankiNote, note) + + if 'tags' in note: + ankiNote.tags = note['tags'] + + def openNewWindow(): + nonlocal ankiNote + + addCards = aqt.dialogs.open('AddCards', self.window()) + + if savedMid: + deck['mid'] = savedMid + + addCards.editor.set_note(ankiNote) + + addCards.activateWindow() + + aqt.dialogs.open('AddCards', self.window()) + addCards.setAndFocusNote(addCards.editor.note) + + currentWindow = aqt.dialogs._dialogs['AddCards'][1] + + if currentWindow is not None: + currentWindow.closeWithCallback(openNewWindow) + else: + openNewWindow() + + return ankiNote.id + + else: + addCards = aqt.dialogs.open('AddCards', self.window()) + addCards.activateWindow() + + return addCards.editor.note.id + + + @util.api() + def guiReviewActive(self): + return self.reviewer().card is not None and self.window().state == 'review' + + + @util.api() + def guiCurrentCard(self): + if not self.guiReviewActive(): + raise Exception('Gui review is not currently active.') + + reviewer = self.reviewer() + card = reviewer.card + model = card.note_type() + note = card.note() + + fields = {} + for info in model['flds']: + order = info['ord'] + name = info['name'] + fields[name] = {'value': note.fields[order], 'order': order} + + buttonList = reviewer._answerButtonList() + return { + 'cardId': card.id, + 'fields': fields, + 'fieldOrder': card.ord, + 'question': util.cardQuestion(card), + 'answer': util.cardAnswer(card), + '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'] + } + + + @util.api() + def guiStartCardTimer(self): + if not self.guiReviewActive(): + return False + + card = self.reviewer().card + if card is not None: + card.startTimer() + return True + + return False + + + @util.api() + def guiShowQuestion(self): + if self.guiReviewActive(): + self.reviewer()._showQuestion() + return True + + return False + + + @util.api() + def guiShowAnswer(self): + if self.guiReviewActive(): + self.window().reviewer._showAnswer() + return True + + return False + + + @util.api() + 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 + + + @util.api() + def guiUndo(self): + self.window().undo() + return True + + + @util.api() + def guiDeckOverview(self, name): + collection = self.collection() + if collection is not None: + deck = collection.decks.by_name(name) + if deck is not None: + collection.decks.select(deck['id']) + self.window().onOverview() + return True + + return False + + + @util.api() + def guiDeckBrowser(self): + self.window().moveToState('deckBrowser') + + + @util.api() + def guiDeckReview(self, name): + if self.guiDeckOverview(name): + self.window().moveToState('review') + return True + + return False + + + @util.api() + def guiImportFile(self, path=None): + """ + Open Import File (Ctrl+Shift+I) dialog with provided file path. + If no path is given, the user will be prompted to select a file. + Only supported from Anki version >=2.1.52 + + path: string + import file path, note on Windows you must use forward slashes. + """ + if anki_version >= (2, 1, 52): + from aqt.import_export.importing import import_file, prompt_for_file_then_import + else: + raise Exception('guiImportFile is only supported from Anki version >=2.1.52') + + if hasattr(Qt, 'WindowStaysOnTopHint'): + # Qt5 + WindowOnTopFlag = Qt.WindowStaysOnTopHint + elif hasattr(Qt, 'WindowType') and hasattr(Qt.WindowType, 'WindowStaysOnTopHint'): + # Qt6 + WindowOnTopFlag = Qt.WindowType.WindowStaysOnTopHint + else: + # Unsupported, don't try to bring window to top + WindowOnTopFlag = None + + # Bring window to top for user to review import settings. + if WindowOnTopFlag is not None: + try: + # [Step 1/2] set always on top flag, show window (it stays on top for now) + self.window().setWindowFlags(self.window().windowFlags() | WindowOnTopFlag) + self.window().show() + finally: + # [Step 2/2] clear always on top flag, show window (it doesn't stay on top anymore) + self.window().setWindowFlags(self.window().windowFlags() & ~WindowOnTopFlag) + self.window().show() + + if path is None: + prompt_for_file_then_import(self.window()) + else: + import_file(self.window(), path) + + + @util.api() + def guiExitAnki(self): + timer = QTimer() + timer.timeout.connect(self.window().close) + timer.start(1000) # 1s should be enough to allow the response to be sent. + + + @util.api() + def guiCheckDatabase(self): + self.window().onCheckDB() + return True + + + @util.api() + def addNotes(self, notes): + results = [] + errs = [] + + for note in notes: + try: + results.append(self.addNote(note)) + except Exception as e: + # I specifically chose to continue, so we gather all the errors of all notes (ie not break) + errs.append(str(e)) + + if errs: + # Roll back the changes so on error nothing happens + self.deleteNotes(results) + raise Exception(str(errs)) + + return results + + + @util.api() + def canAddNotes(self, notes): + results = [] + for note in notes: + results.append(self.canAddNote(note)) + + return results + + @util.api() + def canAddNotesWithErrorDetail(self, notes): + results = [] + for note in notes: + results.append(self.canAddNoteWithErrorDetail(note)) + + return results + + + @util.api() + def exportPackage(self, deck, path, includeSched=False): + collection = self.collection() + if collection is not None: + deck = collection.decks.by_name(deck) + if deck is not None: + exporter = AnkiPackageExporter(collection) + exporter.did = deck['id'] + exporter.includeSched = includeSched + exporter.exportInto(path) + return True + + return False + + + @util.api() + def importPackage(self, path): + collection = self.collection() + if collection is not None: + try: + self.startEditing() + importer = AnkiPackageImporter(collection, path) + importer.run() + except: + raise + else: + return True + + return False + + + @util.api() + def apiReflect(self, scopes=None, actions=None): + if not isinstance(scopes, list): + raise Exception('scopes has invalid value') + if not (actions is None or isinstance(actions, list)): + raise Exception('actions has invalid value') + + cls = type(self) + scopes2 = [] + result = {'scopes': scopes2} + + if 'actions' in scopes: + if actions is None: + actions = dir(cls) + + methodNames = [] + for methodName in actions: + if not isinstance(methodName, str): + pass + method = getattr(cls, methodName, None) + if method is not None and getattr(method, 'api', False): + methodNames.append(methodName) + + scopes2.append('actions') + result['actions'] = methodNames + + return result + + +# +# Entry +# + +# when run inside Anki, `__name__` would be either numeric, +# or, if installed via `link.sh`, `AnkiConnectDev` +if __name__ != "plugin": + if platform.system() == "Windows" and anki_version == (2, 1, 50): + util.patch_anki_2_1_50_having_null_stdout_on_windows() + + Edit.register_with_anki() + + ac = AnkiConnect() + ac.initLogging() + ac.startWebServer() diff --git a/plugin/config.json b/plugin/config.json new file mode 100644 index 0000000..d3b51ea --- /dev/null +++ b/plugin/config.json @@ -0,0 +1,8 @@ +{ + "apiKey": null, + "apiLogPath": null, + "webBindAddress": "127.0.0.1", + "webBindPort": 8765, + "webCorsOriginList": ["http://localhost"], + "ignoreOriginList": [] +} diff --git a/plugin/config.md b/plugin/config.md new file mode 100644 index 0000000..6bacba2 --- /dev/null +++ b/plugin/config.md @@ -0,0 +1 @@ +Read the documentation on the [AnkiConnect](https://foosoft.net/projects/anki-connect/) project page for details. diff --git a/plugin/edit.py b/plugin/edit.py new file mode 100644 index 0000000..53c9661 --- /dev/null +++ b/plugin/edit.py @@ -0,0 +1,458 @@ +import aqt +import aqt.editor +import aqt.browser.previewer +from aqt import gui_hooks +from aqt.qt import Qt, QKeySequence, QShortcut, QCloseEvent, QMainWindow +from aqt.utils import restoreGeom, saveGeom, tooltip +from anki.errors import NotFoundError +from anki.consts import QUEUE_TYPE_SUSPENDED +from anki.utils import ids2str + +from . import anki_version + + +# Edit dialog. Like Edit Current, but: +# * has a Preview button to preview the cards for the note +# * has Previous/Back buttons to navigate the history of the dialog +# * has a Browse button to open the history in the Browser +# * has no bar with the Close button +# +# To register in Anki's dialog system: +# > from .edit import Edit +# > Edit.register_with_anki() +# +# To (re)open (note_id is an integer): +# > Edit.open_dialog_and_show_note_with_id(note_id) + + +DOMAIN_PREFIX = "foosoft.ankiconnect." + + +def get_note_by_note_id(note_id): + return aqt.mw.col.get_note(note_id) + +def is_card_suspended(card): + return card.queue == QUEUE_TYPE_SUSPENDED + +def filter_valid_note_ids(note_ids): + return aqt.mw.col.db.list( + "select id from notes where id in " + ids2str(note_ids) + ) + + +############################################################################## + + +class DecentPreviewer(aqt.browser.previewer.MultiCardPreviewer): + class Adapter: + def get_current_card(self): raise NotImplementedError + def can_select_previous_card(self): raise NotImplementedError + def can_select_next_card(self): raise NotImplementedError + def select_previous_card(self): raise NotImplementedError + def select_next_card(self): raise NotImplementedError + + def __init__(self, adapter: Adapter): + super().__init__(parent=None, mw=aqt.mw, on_close=lambda: None) # noqa + self.adapter = adapter + self.last_card_id = 0 + + def card(self): + return self.adapter.get_current_card() + + def card_changed(self): + current_card_id = self.adapter.get_current_card().id + changed = self.last_card_id != current_card_id + self.last_card_id = current_card_id + return changed + + # the check if we can select next/previous card is needed because + # the buttons sometimes get disabled a tad too late + # and can still be pressed by user. + # this is likely due to Anki sometimes delaying rendering of cards + # in order to avoid rendering them too fast? + def _on_prev_card(self): + if self.adapter.can_select_previous_card(): + self.adapter.select_previous_card() + self.render_card() + + def _on_next_card(self): + if self.adapter.can_select_next_card(): + self.adapter.select_next_card() + self.render_card() + + def _should_enable_prev(self): + return self.showing_answer_and_can_show_question() or \ + self.adapter.can_select_previous_card() + + def _should_enable_next(self): + return self.showing_question_and_can_show_answer() or \ + self.adapter.can_select_next_card() + + def _render_scheduled(self): + super()._render_scheduled() # noqa + self._updateButtons() + + def showing_answer_and_can_show_question(self): + return self._state == "answer" and not self._show_both_sides + + def showing_question_and_can_show_answer(self): + return self._state == "question" + + +class ReadyCardsAdapter(DecentPreviewer.Adapter): + def __init__(self, cards): + self.cards = cards + self.current = 0 + + def get_current_card(self): + return self.cards[self.current] + + def can_select_previous_card(self): + return self.current > 0 + + def can_select_next_card(self): + return self.current < len(self.cards) - 1 + + def select_previous_card(self): + self.current -= 1 + + def select_next_card(self): + self.current += 1 + + +############################################################################## + + +# store note ids instead of notes, as note objects don't implement __eq__ etc +class History: + number_of_notes_to_keep_in_history = 25 + + def __init__(self): + self.note_ids = [] + + def append(self, note): + if note.id in self.note_ids: + self.note_ids.remove(note.id) + self.note_ids.append(note.id) + self.note_ids = self.note_ids[-self.number_of_notes_to_keep_in_history:] + + def has_note_to_left_of(self, note): + return note.id in self.note_ids and note.id != self.note_ids[0] + + def has_note_to_right_of(self, note): + return note.id in self.note_ids and note.id != self.note_ids[-1] + + def get_note_to_left_of(self, note): + note_id = self.note_ids[self.note_ids.index(note.id) - 1] + return get_note_by_note_id(note_id) + + def get_note_to_right_of(self, note): + note_id = self.note_ids[self.note_ids.index(note.id) + 1] + return get_note_by_note_id(note_id) + + def get_last_note(self): # throws IndexError if history empty + return get_note_by_note_id(self.note_ids[-1]) + + def remove_invalid_notes(self): + self.note_ids = filter_valid_note_ids(self.note_ids) + +history = History() + + +# see method `find_cards` of `collection.py` +def trigger_search_for_dialog_history_notes(search_context, use_history_order): + search_context.search = " or ".join( + f"nid:{note_id}" for note_id in history.note_ids + ) + + if use_history_order: + search_context.order = f"""case c.nid { + " ".join( + f"when {note_id} then {n}" + for (n, note_id) in enumerate(reversed(history.note_ids)) + ) + } end asc""" + + +############################################################################## + + +# noinspection PyAttributeOutsideInit +class Edit(aqt.editcurrent.EditCurrent): + dialog_geometry_tag = DOMAIN_PREFIX + "edit" + dialog_registry_tag = DOMAIN_PREFIX + "Edit" + dialog_search_tag = DOMAIN_PREFIX + "edit.history" + + # depending on whether the dialog already exists, + # upon a request to open the dialog via `aqt.dialogs.open()`, + # the manager will call either the constructor or the `reopen` method + def __init__(self, note): + QMainWindow.__init__(self, None, Qt.WindowType.Window) + self.form = aqt.forms.editcurrent.Ui_Dialog() + self.form.setupUi(self) + self.setWindowTitle("Edit") + self.setMinimumWidth(250) + self.setMinimumHeight(400) + restoreGeom(self, self.dialog_geometry_tag) + + self.form.buttonBox.setVisible(False) # hides the Close button bar + self.setup_editor_buttons() + + self.show() + self.bring_to_foreground() + + history.remove_invalid_notes() + history.append(note) + self.show_note(note) + + gui_hooks.operation_did_execute.append(self.on_operation_did_execute) + gui_hooks.editor_did_load_note.append(self.editor_did_load_note) + + def reopen(self, note): + history.append(note) + self.show_note(note) + self.bring_to_foreground() + + def cleanup(self): + gui_hooks.editor_did_load_note.remove(self.editor_did_load_note) + gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) + + self.editor.cleanup() + saveGeom(self, self.dialog_geometry_tag) + aqt.dialogs.markClosed(self.dialog_registry_tag) + + def closeEvent(self, evt: QCloseEvent) -> None: + self.editor.call_after_note_saved(self.cleanup) + + # This method (mostly) solves (at least on my Windows 10 machine) three issues + # with window activation. Without this not even too hacky a fix, + # * When dialog is opened from Yomichan *for the first time* since app start, + # the dialog opens in background (just like Browser does), + # but does not flash in taskbar (unlike Browser); + # * When dialog is opened, closed, *then main window is focused by clicking in it*, + # then dialog is opened from Yomichan again, same issue as above arises; + # * When dialog is restored from minimized state *and main window isn't minimized*, + # opening the dialog from Yomichan does not reliably focus it; + # sometimes it opens in foreground, sometimes in background. + # With this fix, windows nearly always appear in foreground in all three cases. + # In the case of the first two issues, strictly speaking, the fix is not ideal: + # the window appears in background first, and then quickly pops into foreground. + # It is not *too* unsightly, probably, no-one will notice this; + # still, a better solution must be possible. TODO find one! + # + # Note that operation systems, notably Windows, and desktop managers, may restrict + # background applications from raising windows to prevent them from interrupting + # what the user is currently doing. For details, see: + # https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks + # https://doc.qt.io/qt-5/qwidget.html#activateWindow + # https://wiki.qt.io/Technical_FAQ#QWidget_::activateWindow.28.29_-_behavior_under_windows + def bring_to_foreground(self): + aqt.mw.app.processEvents() + self.activateWindow() + self.raise_() + + #################################### hooks enabled during dialog lifecycle + + def on_operation_did_execute(self, changes, handler): + if changes.note_text and handler is not self.editor: + self.reload_notes_after_user_action_elsewhere() + + def editor_did_load_note(self, _editor): + self.enable_disable_next_and_previous_buttons() + + ###################################################### load & reload notes + + # setting editor.card is required for the "Cards…" button to work properly + def show_note(self, note): + self.note = note + cards = note.cards() + + self.editor.set_note(note) + self.editor.card = cards[0] if cards else None + + if any(is_card_suspended(card) for card in cards): + tooltip("Some of the cards associated with this note " + "have been suspended", parent=self) + + def reload_notes_after_user_action_elsewhere(self): + history.remove_invalid_notes() + + try: + self.note.load() # this also updates the fields + except NotFoundError: + try: + self.note = history.get_last_note() + except IndexError: + self.cleanup() + return + + self.show_note(self.note) + + ################################################################## actions + + # search two times, one is to select the current note or its cards, + # and another to show the whole history, while keeping the above selection + # set sort column to our search tag, which: + # * prevents the column sort indicator from being shown + # * serves as a hint for us to show notes or cards in history order + # (user can then click on any of the column names + # to show history cards in the order of their choosing) + def show_browser(self, *_): + def search_input_select_all(hook_browser, *_): + hook_browser.form.searchEdit.lineEdit().selectAll() + gui_hooks.browser_did_change_row.remove(search_input_select_all) + gui_hooks.browser_did_change_row.append(search_input_select_all) + + browser = aqt.dialogs.open("Browser", aqt.mw) + browser.table._state.sort_column = self.dialog_search_tag # noqa + browser.table._set_sort_indicator() # noqa + + browser.search_for(f"nid:{self.note.id}") + browser.table.select_all() + browser.search_for(self.dialog_search_tag) + + def show_preview(self, *_): + if cards := self.note.cards(): + previewer = DecentPreviewer(ReadyCardsAdapter(cards)) + previewer.open() + return previewer + else: + tooltip("No cards found", parent=self) + return None + + def show_previous(self, *_): + if history.has_note_to_left_of(self.note): + self.show_note(history.get_note_to_left_of(self.note)) + + def show_next(self, *_): + if history.has_note_to_right_of(self.note): + self.show_note(history.get_note_to_right_of(self.note)) + + ################################################## button and hotkey setup + + def setup_editor_buttons(self): + gui_hooks.editor_did_init.append(self.add_preview_button) + gui_hooks.editor_did_init_buttons.append(self.add_right_hand_side_buttons) + + # on Anki 2.1.50, browser mode makes the Preview button visible + extra_kwargs = {} if anki_version < (2, 1, 50) else { + "editor_mode": aqt.editor.EditorMode.BROWSER + } + + self.editor = aqt.editor.Editor(aqt.mw, self.form.fieldsArea, self, + **extra_kwargs) + + gui_hooks.editor_did_init_buttons.remove(self.add_right_hand_side_buttons) + gui_hooks.editor_did_init.remove(self.add_preview_button) + + # * on Anki < 2.1.50, make the button via js (`setupEditor` of browser.py); + # also, make a copy of _links so that opening Anki's browser does not + # screw them up as they are apparently shared between instances?! + # the last part seems to have been fixed in Anki 2.1.50 + # * on Anki 2.1.50, the button is created by setting editor mode, + # see above; so we only need to add the link. + def add_preview_button(self, editor): + QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.show_preview) + + if anki_version < (2, 1, 50): + editor._links = editor._links.copy() + editor.web.eval(""" + $editorToolbar.then(({notetypeButtons}) => + notetypeButtons.appendButton( + {component: editorToolbar.PreviewButton, id: 'preview'} + ) + ); + """) + + editor._links["preview"] = lambda _editor: self.show_preview() and None + + # * on Anki < 2.1.50, button style is okay-ish from get-go, + # except when disabled; adding class `btn` fixes that; + # * on Anki 2.1.50, buttons have weird font size and are square'; + # the style below makes them in line with left-hand side buttons + def add_right_hand_side_buttons(self, buttons, editor): + if anki_version < (2, 1, 50): + extra_button_class = "btn" + else: + extra_button_class = "anki-connect-button" + editor.web.eval(""" + (function(){ + const style = document.createElement("style"); + style.innerHTML = ` + .anki-connect-button { + white-space: nowrap; + width: auto; + padding: 0 2px; + font-size: var(--base-font-size); + } + .anki-connect-button:disabled { + pointer-events: none; + opacity: .4; + } + `; + document.head.appendChild(style); + })(); + """) + + def add(cmd, function, label, tip, keys): + button_html = editor.addButton( + icon=None, + cmd=DOMAIN_PREFIX + cmd, + id=DOMAIN_PREFIX + cmd, + func=function, + label=f"  {label}  ", + tip=f"{tip} ({keys})", + keys=keys, + ) + + button_html = button_html.replace('class="', + f'class="{extra_button_class} ') + buttons.append(button_html) + + add("browse", self.show_browser, "Browse", "Browse", "Ctrl+F") + add("previous", self.show_previous, "<", "Previous", "Alt+Left") + add("next", self.show_next, ">", "Next", "Alt+Right") + + def run_javascript_after_toolbar_ready(self, js): + js = f"setTimeout(function() {{ {js} }}, 1)" + if anki_version < (2, 1, 50): + js = f'$editorToolbar.then(({{ toolbar }}) => {js})' + else: + js = f'require("anki/ui").loaded.then(() => {js})' + self.editor.web.eval(js) + + def enable_disable_next_and_previous_buttons(self): + def to_js(boolean): + return "true" if boolean else "false" + + disable_previous = not(history.has_note_to_left_of(self.note)) + disable_next = not(history.has_note_to_right_of(self.note)) + + self.run_javascript_after_toolbar_ready(f""" + document.getElementById("{DOMAIN_PREFIX}previous") + .disabled = {to_js(disable_previous)}; + document.getElementById("{DOMAIN_PREFIX}next") + .disabled = {to_js(disable_next)}; + """) + + ########################################################################## + + @classmethod + def browser_will_search(cls, search_context): + if search_context.search == cls.dialog_search_tag: + trigger_search_for_dialog_history_notes( + search_context=search_context, + use_history_order=cls.dialog_search_tag == + search_context.browser.table._state.sort_column # noqa + ) + + @classmethod + def register_with_anki(cls): + if cls.dialog_registry_tag not in aqt.dialogs._dialogs: # noqa + aqt.dialogs.register_dialog(cls.dialog_registry_tag, cls) + gui_hooks.browser_will_search.append(cls.browser_will_search) + + @classmethod + def open_dialog_and_show_note_with_id(cls, note_id): # raises NotFoundError + note = get_note_by_note_id(note_id) + return aqt.dialogs.open(cls.dialog_registry_tag, note) diff --git a/plugin/util.py b/plugin/util.py new file mode 100644 index 0000000..1857af1 --- /dev/null +++ b/plugin/util.py @@ -0,0 +1,107 @@ +# Copyright 2016-2021 Alex Yatskov +# +# 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 . + +import os +import sys + +import anki +import anki.sync +import aqt +import enum +import itertools + +# +# Utilities +# + +class MediaType(enum.Enum): + Audio = 1 + Video = 2 + Picture = 3 + + +def download(url): + client = anki.sync.AnkiRequestsClient() + client.timeout = setting('webTimeout') / 1000 + + resp = client.get(url) + if resp.status_code != 200: + raise Exception('{} download failed with return code {}'.format(url, resp.status_code)) + + return client.streamContent(resp) + + +def api(*versions): + def decorator(func): + setattr(func, 'versions', versions) + setattr(func, 'api', True) + return func + + return decorator + + +def cardQuestion(card): + if getattr(card, 'question', None) is None: + return card._getQA()['q'] + + return card.question() + + +def cardAnswer(card): + if getattr(card, 'answer', None) is None: + return card._getQA()['a'] + + return card.answer() + + +DEFAULT_CONFIG = { + 'apiKey': None, + 'apiLogPath': None, + 'apiPollInterval': 25, + 'apiVersion': 6, + 'webBacklog': 5, + 'webBindAddress': os.getenv('ANKICONNECT_BIND_ADDRESS', '127.0.0.1'), + 'webBindPort': 8765, + 'webCorsOrigin': os.getenv('ANKICONNECT_CORS_ORIGIN', None), + 'webCorsOriginList': ['http://localhost'], + 'ignoreOriginList': [], + 'webTimeout': 10000, +} + +def setting(key): + try: + return aqt.mw.addonManager.getConfig(__name__).get(key, DEFAULT_CONFIG[key]) + except: + raise Exception('setting {} not found'.format(key)) + + +# see https://github.com/FooSoft/anki-connect/issues/308 +# fixed in https://github.com/ankitects/anki/commit/0b2a226d +def patch_anki_2_1_50_having_null_stdout_on_windows(): + if sys.stdout is None: + sys.stdout = open(os.devnull, "w", encoding="utf8") + + +# ref https://docs.python.org/3.12/library/itertools.html#itertools.batched +if sys.version_info >= (3, 12): + batched = itertools.batched +else: + def batched(iterable, n): + iterator = iter(iterable) + while True: + batch = tuple(itertools.islice(iterator, n)) + if not batch: + break + yield batch diff --git a/plugin/web.py b/plugin/web.py new file mode 100644 index 0000000..067e198 --- /dev/null +++ b/plugin/web.py @@ -0,0 +1,301 @@ +# Copyright 2016-2021 Alex Yatskov +# +# 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 . + +import json +import jsonschema +import select +import socket + +from . import util + +# +# WebRequest +# + +class WebRequest: + def __init__(self, method, headers, body): + self.method = method + self.headers = headers + self.body = body + + +# +# WebClient +# + +class WebClient: + def __init__(self, sock, handler): + self.sock = sock + self.handler = handler + self.readBuff = bytes() + self.writeBuff = bytes() + + + def advance(self, recvSize=1024): + if self.sock is None: + return False + + rlist, wlist = select.select([self.sock], [self.sock], [], 0)[:2] + self.sock.settimeout(5.0) + + if rlist: + while True: + try: + msg = self.sock.recv(recvSize) + except (ConnectionResetError, socket.timeout): + self.close() + return False + 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) + break + + + + if wlist and self.writeBuff: + try: + length = self.sock.send(self.writeBuff) + self.writeBuff = self.writeBuff[length:] + if not self.writeBuff: + self.close() + return False + except: + self.close() + return False + return True + + + def close(self): + if self.sock is not None: + self.sock.close() + self.sock = None + + self.readBuff = bytes() + self.writeBuff = bytes() + + + def parseRequest(self, data): + parts = data.split('\r\n\r\n'.encode('utf-8'), 1) + if len(parts) == 1: + return None, 0 + + lines = parts[0].split('\r\n'.encode('utf-8')) + method = None + + if len(lines) > 0: + request_line_parts = lines[0].split(' '.encode('utf-8')) + method = request_line_parts[0].upper() if len(request_line_parts) > 0 else None + + headers = {} + for line in lines[1:]: + pair = line.split(': '.encode('utf-8')) + headers[pair[0].lower()] = pair[1] if len(pair) > 1 else None + + headerLength = len(parts[0]) + 4 + bodyLength = int(headers.get('content-length'.encode('utf-8'), 0)) + totalLength = headerLength + bodyLength + + if totalLength > len(data): + return None, 0 + + body = data[headerLength : totalLength] + return WebRequest(method, headers, body), totalLength + +# +# WebServer +# + +class WebServer: + def __init__(self, handler): + self.handler = handler + self.clients = [] + self.sock = None + + + 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)) + + + def advanceClients(self): + self.clients = list(filter(lambda c: c.advance(), self.clients)) + + + def listen(self): + 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) + self.sock.bind((util.setting('webBindAddress'), util.setting('webBindPort'))) + self.sock.listen(util.setting('webBacklog')) + + + def handlerWrapper(self, req): + allowed, corsOrigin = self.allowOrigin(req) + + if req.method == b'OPTIONS': + body = ''.encode('utf-8') + headers = self.buildHeaders(corsOrigin, body) + + if b'access-control-request-private-network' in req.headers and ( + req.headers[b'access-control-request-private-network'] == b'true'): + # include this header so that if a public origin is included in the whitelist, + # then browsers won't fail requests due to the private network access check + headers.append(['Access-Control-Allow-Private-Network', 'true']) + + return self.buildResponse(headers, body) + + try: + params = json.loads(req.body.decode('utf-8')) + jsonschema.validate(params, request_schema) + except (ValueError, jsonschema.ValidationError) as e: + if allowed: + if len(req.body) == 0: + body = json.dumps({"apiVersion": f"AnkiConnect v.{util.setting('apiVersion')}"}).encode('utf-8') + else: + reply = format_exception_reply(util.setting('apiVersion'), e) + body = json.dumps(reply).encode('utf-8') + headers = self.buildHeaders(corsOrigin, body) + return self.buildResponse(headers, body) + else: + params = {} # trigger the 403 response below + + if allowed or params.get('action', '') == 'requestPermission': + if params.get('action', '') == 'requestPermission': + params['params'] = params.get('params', {}) + params['params']['allowed'] = allowed + params['params']['origin'] = b'origin' in req.headers and req.headers[b'origin'].decode() or '' + if not allowed : + corsOrigin = params['params']['origin'] + + body = json.dumps(self.handler(params)).encode('utf-8') + headers = self.buildHeaders(corsOrigin, body) + else : + headers = [ + ['HTTP/1.1 403 Forbidden', None], + ['Access-Control-Allow-Origin', corsOrigin], + ['Access-Control-Allow-Headers', '*'] + ] + body = ''.encode('utf-8') + + return self.buildResponse(headers, body) + + + def allowOrigin(self, req): + # handle multiple cors origins by checking the 'origin'-header against the allowed origin list from the config + webCorsOriginList = util.setting('webCorsOriginList') + + # keep support for deprecated 'webCorsOrigin' field, as long it is not removed + webCorsOrigin = util.setting('webCorsOrigin') + if webCorsOrigin: + webCorsOriginList.append(webCorsOrigin) + + allowed = False + corsOrigin = 'http://localhost' + allowAllCors = '*' in webCorsOriginList # allow CORS for all domains + + if allowAllCors: + corsOrigin = '*' + allowed = True + elif b'origin' in req.headers: + originStr = req.headers[b'origin'].decode() + if originStr in webCorsOriginList : + corsOrigin = originStr + allowed = True + elif 'http://localhost' in webCorsOriginList and ( + originStr == 'http://127.0.0.1' or originStr == 'https://127.0.0.1' or # allow 127.0.0.1 if localhost allowed + originStr.startswith('http://127.0.0.1:') or originStr.startswith('http://127.0.0.1:') or + originStr.startswith('chrome-extension://') or originStr.startswith('moz-extension://') or originStr.startswith('safari-web-extension://') ) : # allow chrome, firefox and safari extension if localhost allowed + corsOrigin = originStr + allowed = True + else: + allowed = True + + return allowed, corsOrigin + + + def buildHeaders(self, corsOrigin, body): + return [ + ['HTTP/1.1 200 OK', None], + ['Content-Type', 'application/json'], + ['Access-Control-Allow-Origin', corsOrigin], + ['Access-Control-Allow-Headers', '*'], + ['Content-Length', str(len(body))] + ] + + + def buildResponse(self, headers, body): + resp = bytes() + for key, value in headers: + if value is None: + resp += '{}\r\n'.format(key).encode('utf-8') + else: + resp += '{}: {}\r\n'.format(key, value).encode('utf-8') + + resp += '\r\n'.encode('utf-8') + 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 = [] + + +def format_success_reply(api_version, result): + if api_version <= 4: + return result + else: + return {"result": result, "error": None} + + +def format_exception_reply(_api_version, exception): + return {"result": None, "error": str(exception)} + + +request_schema = { + "type": "object", + "properties": { + "action": {"type": "string", "minLength": 1}, + "version": {"type": "integer"}, + "params": {"type": "object"}, + }, + "required": ["action"], +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..208abe2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,299 @@ +import concurrent.futures +import time +from contextlib import contextmanager +from dataclasses import dataclass + +import aqt.operations.note +import pytest +import anki.collection +from _pytest.monkeypatch import MonkeyPatch # noqa +from pytest_anki._launch import anki_running, temporary_user # noqa +from waitress import wasyncore + +from plugin import AnkiConnect, anki_version +from plugin.edit import Edit +from plugin.util import DEFAULT_CONFIG + +try: + from PyQt6 import QtTest +except ImportError: + from PyQt5 import QtTest + + +ac = AnkiConnect() + + +# wait for n seconds, while events are being processed +def wait(seconds): + milliseconds = int(seconds * 1000) + QtTest.QTest.qWait(milliseconds) # noqa + + +def wait_until(booleanish_function, at_most_seconds=30): + deadline = time.time() + at_most_seconds + + while time.time() < deadline: + if booleanish_function(): + return + wait(0.01) + + raise Exception(f"Function {booleanish_function} never once returned " + f"a positive value in {at_most_seconds} seconds") + + +def delete_model(model_name): + model = ac.collection().models.byName(model_name) + ac.collection().models.remove(model["id"]) + + +def close_all_dialogs_and_wait_for_them_to_run_closing_callbacks(): + aqt.dialogs.closeAll(onsuccess=lambda: None) + wait_until(aqt.dialogs.allClosed) + + +def get_dialog_instance(name): + return aqt.dialogs._dialogs[name][1] # noqa + + +# waitress is a WSGI server that Anki starts to serve css etc to its web views. +# it seems to have a race condition issue; +# the main loop thread is trying to `select.select` the sockets +# which a worker thread is closing because of a dead connection. +# this is especially pronounced in tests, +# as we open and close windows rapidly--and so web views and their connections. +# this small patch makes waitress skip actually closing the sockets +# (unless the server is shutting down--if it is, loop exceptions are ignored). +# while the unclosed sockets might accumulate, +# this should not pose an issue in test environment. +# see https://github.com/Pylons/waitress/issues/374 +@contextmanager +def waitress_patched_to_prevent_it_from_dying(): + original_close = wasyncore.dispatcher.close + sockets_that_must_not_be_garbage_collected = [] # lists are thread-safe + + def close(self): + if not aqt.mw.mediaServer.is_shutdown: + sockets_that_must_not_be_garbage_collected.append(self.socket) + self.socket = None + original_close(self) + + with MonkeyPatch().context() as monkey: + monkey.setattr(wasyncore.dispatcher, "close", close) + yield + + +@contextmanager +def anki_patched_to_prevent_backups(): + with MonkeyPatch().context() as monkey: + if anki_version < (2, 1, 50): + monkey.setitem(aqt.profiles.profileConf, "numBackups", 0) + else: + monkey.setattr(anki.collection.Collection, "create_backup", + lambda *args, **kwargs: True) + yield + + +@contextmanager +def empty_anki_session_started(): + with waitress_patched_to_prevent_it_from_dying(): + with anki_patched_to_prevent_backups(): + with anki_running( + qtbot=None, # noqa + enable_web_debugging=False, + profile_name="test_user", + ) as session: + yield session + + +@contextmanager +def profile_created_and_loaded(session): + with temporary_user(session.base, "test_user", "en_US"): + with session.profile_loaded(): + yield session + + +@contextmanager +def anki_connect_config_loaded(session, web_bind_port): + with session.addon_config_created( + package_name="plugin", + default_config=DEFAULT_CONFIG, + user_config={**DEFAULT_CONFIG, "webBindPort": web_bind_port} + ): + yield + + +@contextmanager +def current_decks_and_models_etc_preserved(): + deck_names_before = ac.deckNames() + model_names_before = ac.modelNames() + + try: + yield + finally: + deck_names_after = ac.deckNames() + model_names_after = ac.modelNames() + + deck_names_to_delete = {*deck_names_after} - {*deck_names_before} + model_names_to_delete = {*model_names_after} - {*model_names_before} + + ac.deleteDecks(decks=deck_names_to_delete, cardsToo=True) + for model_name in model_names_to_delete: + delete_model(model_name) + + ac.guiDeckBrowser() + + +@dataclass +class Setup: + deck_id: int + note1_id: int + note2_id: int + note1_card_ids: "list[int]" + note2_card_ids: "list[int]" + card_ids: "list[int]" + + +def set_up_test_deck_and_test_model_and_two_notes(): + ac.createModel( + modelName="test_model", + inOrderFields=["field1", "field2"], + cardTemplates=[ + {"Front": "{{field1}}", "Back": "{{field2}}"}, + {"Front": "{{field2}}", "Back": "{{field1}}"} + ], + css="* {}", + ) + + deck_id = ac.createDeck("test_deck") + + note1_id = ac.addNote(dict( + deckName="test_deck", + modelName="test_model", + fields={"field1": "note1 field1", "field2": "note1 field2"}, + tags={"tag1"}, + )) + + note2_id = ac.addNote(dict( + deckName="test_deck", + modelName="test_model", + fields={"field1": "note2 field1", "field2": "note2 field2"}, + tags={"tag2"}, + )) + + note1_card_ids = ac.findCards(query=f"nid:{note1_id}") + note2_card_ids = ac.findCards(query=f"nid:{note2_id}") + card_ids = ac.findCards(query="deck:test_deck") + + return Setup( + deck_id=deck_id, + note1_id=note1_id, + note2_id=note2_id, + note1_card_ids=note1_card_ids, + note2_card_ids=note2_card_ids, + card_ids=card_ids, + ) + + +############################################################################# + + +def pytest_addoption(parser): + parser.addoption("--tear-down-profile-after-each-test", + action="store_true", + default=True) + parser.addoption("--no-tear-down-profile-after-each-test", "-T", + action="store_false", + dest="tear_down_profile_after_each_test") + + +def pytest_report_header(config): + if config.option.forked: + return "test isolation: perfect; each test is run in a separate process" + if config.option.tear_down_profile_after_each_test: + return "test isolation: good; user profile is torn down after each test" + else: + return "test isolation: poor; only newly created decks and models " \ + "are cleaned up between tests" + + +@pytest.fixture(autouse=True) +def run_background_tasks_on_main_thread(request, monkeypatch): # noqa + """ + Makes background operations such as card deletion execute on main thread + and execute the callback immediately + """ + def run_in_background(task, on_done=None, kwargs=None): + future = concurrent.futures.Future() + + try: + future.set_result(task(**kwargs if kwargs is not None else {})) + except BaseException as e: + future.set_exception(e) + + if on_done is not None: + on_done(future) + + monkeypatch.setattr(aqt.mw.taskman, "run_in_background", run_in_background) + + +# don't use run_background_tasks_on_main_thread for tests that don't run Anki +def pytest_generate_tests(metafunc): + if ( + run_background_tasks_on_main_thread.__name__ in metafunc.fixturenames + and session_scope_empty_session.__name__ not in metafunc.fixturenames + ): + metafunc.fixturenames.remove(run_background_tasks_on_main_thread.__name__) + + +@pytest.fixture(scope="session") +def session_scope_empty_session(): + with empty_anki_session_started() as session: + yield session + + +@pytest.fixture(scope="session") +def session_scope_session_with_profile_loaded(session_scope_empty_session): + with profile_created_and_loaded(session_scope_empty_session): + yield session_scope_empty_session + + +@pytest.fixture +def session_with_profile_loaded(session_scope_empty_session, request): + """ + Like anki_session fixture from pytest-anki, but: + * Default profile is loaded + * It's relying on session-wide app instance so that + it can be used without forking every test; + this can be useful to speed up tests and also + to examine Anki's stdout/stderr, which is not visible with forking. + * If command line option --no-tear-down-profile-after-each-test is passed, + only the newly created decks and models are deleted. + Otherwise, the profile is completely torn down after each test. + Tearing down the profile is significantly slower. + """ + if request.config.option.tear_down_profile_after_each_test: + with profile_created_and_loaded(session_scope_empty_session): + yield session_scope_empty_session + else: + session = request.getfixturevalue( + session_scope_session_with_profile_loaded.__name__ + ) + with current_decks_and_models_etc_preserved(): + yield session + + +@pytest.fixture +def setup(session_with_profile_loaded): + """ + Like session_with_profile_loaded, but also: + * Added are: + * A deck `test_deck` + * A model `test_model` with fields `filed1` and `field2` + and two cards per note + * Two notes with two valid cards each using the above deck and model + * Edit dialog is registered with dialog manager + * Any dialogs, if open, are safely closed on exit + """ + Edit.register_with_anki() + yield set_up_test_deck_and_test_model_and_two_notes() + close_all_dialogs_and_wait_for_them_to_run_closing_callbacks() diff --git a/tests/test_cards.py b/tests/test_cards.py new file mode 100755 index 0000000..0ce93b2 --- /dev/null +++ b/tests/test_cards.py @@ -0,0 +1,93 @@ +import pytest +from anki.errors import NotFoundError # noqa + +from conftest import ac + + +def test_findCards(setup): + card_ids = ac.findCards(query="deck:test_deck") + assert len(card_ids) == 4 + + +class TestEaseFactors: + def test_setEaseFactors(self, setup): + result = ac.setEaseFactors(cards=setup.card_ids, easeFactors=[4200] * 4) + assert result == [True] * 4 + + def test_setEaseFactors_with_invalid_card_id(self, setup): + result = ac.setEaseFactors(cards=[123], easeFactors=[4200]) + assert result == [False] + + def test_getEaseFactors(self, setup): + ac.setEaseFactors(cards=setup.card_ids, easeFactors=[4200] * 4) + result = ac.getEaseFactors(cards=setup.card_ids) + assert result == [4200] * 4 + + def test_getEaseFactors_with_invalid_card_id(self, setup): + assert ac.getEaseFactors(cards=[123]) == [None] + + +class TestSuspending: + def test_suspend(self, setup): + assert ac.suspend(cards=setup.card_ids) is True + + def test_suspend_fails_with_incorrect_id(self, setup): + with pytest.raises(NotFoundError): + assert ac.suspend(cards=[123]) + + def test_areSuspended_returns_False_for_regular_cards(self, setup): + result = ac.areSuspended(cards=setup.card_ids) + assert result == [False] * 4 + + def test_areSuspended_returns_True_for_suspended_cards(self, setup): + ac.suspend(setup.card_ids) + result = ac.areSuspended(cards=setup.card_ids) + assert result == [True] * 4 + + +def test_areDue_returns_True_for_new_cards(setup): + result = ac.areDue(cards=setup.card_ids) + assert result == [True] * 4 + + +def test_getIntervals(setup): + ac.getIntervals(cards=setup.card_ids, complete=False) + ac.getIntervals(cards=setup.card_ids, complete=True) + + +def test_cardsToNotes(setup): + result = ac.cardsToNotes(cards=setup.card_ids) + assert {*result} == {setup.note1_id, setup.note2_id} + + +class TestCardInfo: + def test_with_valid_ids(self, setup): + result = ac.cardsInfo(cards=setup.card_ids) + assert [item["cardId"] for item in result] == setup.card_ids + + def test_with_incorrect_id(self, setup): + result = ac.cardsInfo(cards=[123]) + assert result == [{}] + + +def test_forgetCards(setup): + ac.forgetCards(cards=setup.card_ids) + + +def test_relearnCards(setup): + ac.relearnCards(cards=setup.card_ids) + + +class TestAnswerCards: + def test_answerCards(self, setup): + ac.scheduler().reset() + answers = [ + {"cardId": a, "ease": b} for a, b in zip(setup.card_ids, [2, 1, 4, 3]) + ] + result = ac.answerCards(answers) + assert result == [True] * 4 + + def test_answerCards_with_invalid_card_id(self, setup): + ac.scheduler().reset() + result = ac.answerCards([{"cardId": 123, "ease": 2}]) + assert result == [False] diff --git a/tests/test_decks.py b/tests/test_decks.py new file mode 100755 index 0000000..0447347 --- /dev/null +++ b/tests/test_decks.py @@ -0,0 +1,74 @@ +import pytest + +from conftest import ac + + +def test_deckNames(session_with_profile_loaded): + result = ac.deckNames() + assert result == ["Default"] + + +def test_deckNamesAndIds(session_with_profile_loaded): + result = ac.deckNamesAndIds() + assert result == {"Default": 1} + + +def test_createDeck(session_with_profile_loaded): + ac.createDeck("foo") + assert {*ac.deckNames()} == {"Default", "foo"} + + +def test_changeDeck(setup): + ac.changeDeck(cards=setup.card_ids, deck="bar") + assert "bar" in ac.deckNames() + + +def test_deleteDeck(setup): + before = ac.deckNames() + ac.deleteDecks(decks=["test_deck"], cardsToo=True) + after = ac.deckNames() + assert {*before} - {*after} == {"test_deck"} + + +def test_deleteDeck_must_be_called_with_cardsToo_set_to_True_on_later_api(setup): + with pytest.raises(Exception): + ac.deleteDecks(decks=["test_deck"]) + with pytest.raises(Exception): + ac.deleteDecks(decks=["test_deck"], cardsToo=False) + + +def test_getDeckConfig(session_with_profile_loaded): + result = ac.getDeckConfig(deck="Default") + assert result["name"] == "Default" + + +def test_saveDeckConfig(session_with_profile_loaded): + config = ac.getDeckConfig(deck="Default") + result = ac.saveDeckConfig(config=config) + assert result is True + + +def test_setDeckConfigId(session_with_profile_loaded): + result = ac.setDeckConfigId(decks=["Default"], configId=1) + assert result is True + + +def test_cloneDeckConfigId(session_with_profile_loaded): + result = ac.cloneDeckConfigId(cloneFrom=1, name="test") + assert isinstance(result, int) + + +def test_removedDeckConfigId(session_with_profile_loaded): + new_config_id = ac.cloneDeckConfigId(cloneFrom=1, name="test") + assert ac.removeDeckConfigId(configId=new_config_id) is True + + +def test_removedDeckConfigId_fails_with_invalid_id(session_with_profile_loaded): + new_config_id = ac.cloneDeckConfigId(cloneFrom=1, name="test") + assert ac.removeDeckConfigId(configId=new_config_id) is True + assert ac.removeDeckConfigId(configId=new_config_id) is False + + +def test_getDeckStats(session_with_profile_loaded): + result = ac.getDeckStats(decks=["Default"]) + assert list(result.values())[0]["name"] == "Default" diff --git a/tests/test_edit.py b/tests/test_edit.py new file mode 100644 index 0000000..71b1615 --- /dev/null +++ b/tests/test_edit.py @@ -0,0 +1,253 @@ +from dataclasses import dataclass +from unittest.mock import MagicMock + +import aqt.operations.note +import pytest + +from conftest import get_dialog_instance, wait_until +from plugin.edit import Edit, DecentPreviewer, history, DOMAIN_PREFIX + + +NOTHING = object() + + +class Value: + def __init__(self): + self.value = NOTHING + + def set(self, value): + self.value = value + + def has_been_set(self): + return self.value is not NOTHING + + +@dataclass +class JavascriptDialogButtonManipulator: + dialog: ... + + def eval_js(self, js): + evaluation_result = Value() + self.dialog.editor.web.evalWithCallback(js, evaluation_result.set) + wait_until(evaluation_result.has_been_set) + return evaluation_result.value + + def wait_until_toolbar_buttons_are_ready(self): + ready_flag = Value() + self.dialog.editor._links["set_ready_flag"] = ready_flag.set # noqa + self.dialog.run_javascript_after_toolbar_ready("pycmd('set_ready_flag');") + wait_until(ready_flag.has_been_set) + + # preview button doesn't have an id, so find by label + def click_preview_button(self): + self.eval_js(""" + document.evaluate("//button[text()='Preview']", document) + .iterateNext() + .click() + """) + + def click_button(self, button_id): + self.eval_js(f""" + document.getElementById("{DOMAIN_PREFIX}{button_id}").click() + """) + + def is_button_disabled(self, button_id): + return self.eval_js(f""" + document.getElementById("{DOMAIN_PREFIX}{button_id}").disabled + """) + + +############################################################################## + + +def test_edit_dialog_opens(setup): + Edit.open_dialog_and_show_note_with_id(setup.note1_id) + + +def test_edit_dialog_opens_only_once(setup): + dialog1 = Edit.open_dialog_and_show_note_with_id(setup.note1_id) + dialog2 = Edit.open_dialog_and_show_note_with_id(setup.note1_id) + assert dialog1 is dialog2 + + +def test_edit_dialog_fails_to_open_with_invalid_note(setup): + with pytest.raises(Exception): + Edit.open_dialog_and_show_note_with_id(123) + + +class TestBrowser: + @staticmethod + def get_selected_card_ids(): + return get_dialog_instance("Browser").table.get_selected_card_ids() + + def test_dialog_opens(self, setup): + dialog = Edit.open_dialog_and_show_note_with_id(setup.note1_id) + dialog.show_browser() + + def test_selects_cards_of_last_note(self, setup): + Edit.open_dialog_and_show_note_with_id(setup.note1_id) + Edit.open_dialog_and_show_note_with_id(setup.note2_id).show_browser() + + assert {*self.get_selected_card_ids()} == {*setup.note2_card_ids} + + def test_selects_cards_of_note_before_last_after_previous_button_pressed(self, setup): + Edit.open_dialog_and_show_note_with_id(setup.note1_id) + dialog = Edit.open_dialog_and_show_note_with_id(setup.note2_id) + + def verify_that_the_table_shows_note2_cards_then_note1_cards(): + get_dialog_instance("Browser").table.select_all() + assert {*self.get_selected_card_ids()[:2]} == {*setup.note2_card_ids} + assert {*self.get_selected_card_ids()[2:]} == {*setup.note1_card_ids} + + dialog.show_previous() + dialog.show_browser() + assert {*self.get_selected_card_ids()} == {*setup.note1_card_ids} + verify_that_the_table_shows_note2_cards_then_note1_cards() + + dialog.show_next() + dialog.show_browser() + assert {*self.get_selected_card_ids()} == {*setup.note2_card_ids} + verify_that_the_table_shows_note2_cards_then_note1_cards() + + +class TestPreviewDialog: + def test_opens(self, setup): + edit_dialog = Edit.open_dialog_and_show_note_with_id(setup.note1_id) + edit_dialog.show_preview() + + @pytest.fixture + def dialog(self, setup): + edit_dialog = Edit.open_dialog_and_show_note_with_id(setup.note1_id) + preview_dialog: DecentPreviewer = edit_dialog.show_preview() + + def press_next_button(times=0): + for _ in range(times): + preview_dialog._last_render = 0 # render without delay + preview_dialog._on_next() + + preview_dialog.press_next_button = press_next_button + + yield preview_dialog + + @pytest.mark.parametrize( + "next_button_presses, current_card, " + "showing_question_only, previous_enabled, next_enabled", + [ + pytest.param(0, 0, True, False, True, + id="next button pressed 0 times; first card, question"), + pytest.param(1, 0, False, True, True, + id="next button pressed 1 time; first card, answer"), + pytest.param(2, 1, True, True, True, + id="next button pressed 2 times; second card, question"), + pytest.param(3, 1, False, True, False, + id="next button pressed 3 times; second card, answer"), + pytest.param(4, 1, False, True, False, + id="next button pressed 4 times; second card still, answer"), + ] + ) + def test_navigation(self, dialog, next_button_presses, current_card, + showing_question_only, previous_enabled, next_enabled): + dialog.press_next_button(times=next_button_presses) + assert dialog.adapter.current == current_card + assert dialog.showing_question_and_can_show_answer() is showing_question_only + assert dialog._should_enable_prev() is previous_enabled + assert dialog._should_enable_next() is next_enabled + + +class TestButtons: + @pytest.fixture + def manipulator(self, setup): + dialog = Edit.open_dialog_and_show_note_with_id(setup.note1_id) + return JavascriptDialogButtonManipulator(dialog) + + def test_preview_button_can_be_clicked(self, manipulator, monkeypatch): + monkeypatch.setattr(manipulator.dialog, "show_preview", MagicMock()) + manipulator.wait_until_toolbar_buttons_are_ready() + manipulator.click_preview_button() + wait_until(lambda: manipulator.dialog.show_preview.call_count == 1) + + def test_addon_buttons_can_be_clicked(self, manipulator): + manipulator.wait_until_toolbar_buttons_are_ready() + manipulator.click_button(button_id="browse") + wait_until(lambda: get_dialog_instance("Browser") is not None) + + def test_addon_buttons_get_disabled_enabled(self, setup, manipulator): + Edit.open_dialog_and_show_note_with_id(setup.note2_id) + manipulator.wait_until_toolbar_buttons_are_ready() + assert manipulator.is_button_disabled("previous") is False + assert manipulator.is_button_disabled("next") is True + + +class TestHistory: + @pytest.fixture(autouse=True) + def cleanup(self): + history.note_ids = [] + + def test_single_note(self, setup): + assert history.note_ids == [] + Edit.open_dialog_and_show_note_with_id(setup.note1_id) + assert history.note_ids == [setup.note1_id] + + def test_two_notes(self, setup): + Edit.open_dialog_and_show_note_with_id(setup.note1_id) + Edit.open_dialog_and_show_note_with_id(setup.note2_id) + assert history.note_ids == [setup.note1_id, setup.note2_id] + + def test_old_note_reopened(self, setup): + Edit.open_dialog_and_show_note_with_id(setup.note1_id) + Edit.open_dialog_and_show_note_with_id(setup.note2_id) + Edit.open_dialog_and_show_note_with_id(setup.note1_id) + assert history.note_ids == [setup.note2_id, setup.note1_id] + + def test_navigation(self, setup): + dialog = Edit.open_dialog_and_show_note_with_id(setup.note1_id) + Edit.open_dialog_and_show_note_with_id(setup.note2_id) + + dialog.show_previous() + assert dialog.note.id == setup.note1_id + + dialog.show_previous() + assert dialog.note.id == setup.note1_id + + dialog.show_next() + assert dialog.note.id == setup.note2_id + + dialog.show_next() + assert dialog.note.id == setup.note2_id + + +class TestNoteDeletionElsewhere: + @pytest.fixture + def delete_note(self, run_background_tasks_on_main_thread): + """ + Yields a function that accepts a single note id and deletes the note, + running the required hooks in sync + """ + return ( + lambda note_id: aqt.operations.note + .remove_notes(parent=None, note_ids=[note_id]) # noqa + .run_in_background() + ) + + @staticmethod + def edit_dialog_is_open(): + return aqt.dialogs._dialogs[Edit.dialog_registry_tag][1] is not None # noqa + + @pytest.fixture + def dialog(self, setup): + Edit.open_dialog_and_show_note_with_id(setup.note1_id) + yield Edit.open_dialog_and_show_note_with_id(setup.note2_id) + + def test_one_of_the_history_notes_is_deleted_and_dialog_stays(self, + setup, dialog, delete_note): + assert dialog.note.id == setup.note2_id + + delete_note(setup.note2_id) + assert self.edit_dialog_is_open() + assert dialog.note.id == setup.note1_id + + def test_all_of_the_history_notes_are_deleted_and_dialog_closes(self, + setup, dialog, delete_note): + delete_note(setup.note1_id) + delete_note(setup.note2_id) + assert not self.edit_dialog_is_open() diff --git a/tests/test_graphical.py b/tests/test_graphical.py new file mode 100755 index 0000000..356fc85 --- /dev/null +++ b/tests/test_graphical.py @@ -0,0 +1,139 @@ +import pytest +from unittest import mock + +from conftest import ac, anki_version, wait_until, \ + close_all_dialogs_and_wait_for_them_to_run_closing_callbacks, \ + get_dialog_instance + + +def test_guiBrowse(setup): + ac.guiBrowse() + + +def test_guiDeckBrowser(setup): + ac.guiDeckBrowser() + + +# todo executing this test without running background tasks on main thread +# rarely causes media server (`aqt.mediasrv`) to fail: +# its `run` method raises OSError: invalid file descriptor. +# this can cause other tests to fail to tear down; +# particularly, any dialogs with editor may fail to close +# due to their trying to save the note first, which is done via web view, +# which fails to complete due to corrupt media server. investigate? +def test_guiCheckDatabase(setup, run_background_tasks_on_main_thread): + ac.guiCheckDatabase() + + +def test_guiDeckOverview(setup): + assert ac.guiDeckOverview(name="test_deck") is True + + +def test_guiImportFile(setup): + if anki_version >= (2, 1, 52): + with mock.patch('aqt.import_export.importing.prompt_for_file_then_import') as mock_prompt_for_file_then_import: + mock_prompt_for_file_then_import.return_value = True + ac.guiImportFile() + + +class TestAddCards: + note = { + "deckName": "test_deck", + "modelName": "Basic", + "fields": {"Front": "new front1", "Back": "new back1"}, + "tags": ["tag1"] + } + + # an actual small image, you can see it if you run the test with GUI + # noinspection SpellCheckingInspection + base64_gif = "R0lGODlhBQAEAHAAACwAAAAABQAEAIH///8AAAAAAAAAAAACB0QMqZcXDwoAOw==" + + picture = { + "picture": [ + { + "data": base64_gif, + "filename": "smiley.gif", + "fields": ["Front"], + } + ] + } + + @staticmethod + def click_on_add_card_dialog_save_button(): + dialog = get_dialog_instance("AddCards") + dialog.addButton.click() + + # todo previously, these tests were verifying + # that the return value of `guiAddCards` is `int`. + # while it is indeed `int`, on modern Anki it is also always a `0`, + # so we consider it useless. update documentation? + def test_without_note(self, setup): + ac.guiAddCards() + + def test_with_note(self, setup): + ac.guiAddCards(note=self.note) + self.click_on_add_card_dialog_save_button() + close_all_dialogs_and_wait_for_them_to_run_closing_callbacks() + + assert len(ac.findCards(query="new")) == 1 + + def test_with_note_and_a_picture(self, setup): + ac.guiAddCards(note={**self.note, **self.picture}) + self.click_on_add_card_dialog_save_button() + close_all_dialogs_and_wait_for_them_to_run_closing_callbacks() + + assert len(ac.findCards(query="new")) == 1 + assert ac.retrieveMediaFile(filename="smiley.gif") == self.base64_gif + + +class TestReviewActions: + @pytest.fixture + def reviewing_started(self, setup): + assert ac.guiDeckReview(name="test_deck") is True + + def test_startCardTimer(self, reviewing_started): + assert ac.guiStartCardTimer() is True + + def test_guiShowQuestion(self, reviewing_started): + assert ac.guiShowQuestion() is True + assert ac.reviewer().state == "question" + + def test_guiShowAnswer(self, reviewing_started): + assert ac.guiShowAnswer() is True + assert ac.reviewer().state == "answer" + + def test_guiAnswerCard(self, reviewing_started): + ac.guiShowAnswer() + reviews_before = ac.cardReviews(deck="test_deck", startID=0) + assert ac.guiAnswerCard(ease=4) is True + + reviews_after = ac.cardReviews(deck="test_deck", startID=0) + assert len(reviews_after) == len(reviews_before) + 1 + + def test_guiUndo(self, reviewing_started): + ac.guiShowAnswer() + reviews_before = ac.cardReviews(deck="test_deck", startID=0) + assert ac.guiAnswerCard(ease=4) is True + + reviews_after_answer = ac.cardReviews(deck="test_deck", startID=0) + assert len(reviews_after_answer) == len(reviews_before) + 1 + + assert ac.guiUndo() is True + + reviews_after_undo = ac.cardReviews(deck="test_deck", startID=0) + assert len(reviews_after_undo) == len(reviews_before) + + +class TestSelectedNotes: + def test_with_valid_deck_query(self, setup): + ac.guiBrowse(query="deck:test_deck") + wait_until(ac.guiSelectedNotes) + assert ac.guiSelectedNotes()[0] in {setup.note1_id, setup.note2_id} + + + def test_with_invalid_deck_query(self, setup): + ac.guiBrowse(query="deck:test_deck") + wait_until(ac.guiSelectedNotes) + + ac.guiBrowse(query="deck:invalid") + wait_until(lambda: not ac.guiSelectedNotes()) diff --git a/tests/test_media.py b/tests/test_media.py new file mode 100755 index 0000000..fde46e8 --- /dev/null +++ b/tests/test_media.py @@ -0,0 +1,56 @@ +import base64 +import os.path + +from conftest import ac + + +FILENAME = "_test.txt" +BASE64_DATA_1 = base64.b64encode(b"test 1").decode("ascii") +BASE64_DATA_2 = base64.b64encode(b"test 2").decode("ascii") + + +def store_one_media_file(): + return ac.storeMediaFile(filename=FILENAME, data=BASE64_DATA_1) + + +def store_two_media_files(): + filename_1 = ac.storeMediaFile(filename=FILENAME, data=BASE64_DATA_1) + filename_2 = ac.storeMediaFile(filename=FILENAME, data=BASE64_DATA_2, + deleteExisting=False) + return filename_1, filename_2 + + +############################################################################## + + +def test_storeMediaFile_one_file(session_with_profile_loaded): + filename_1 = store_one_media_file() + assert FILENAME == filename_1 + + +def test_storeMediaFile_two_files_with_the_same_name(session_with_profile_loaded): + filename_1, filename_2 = store_two_media_files() + assert FILENAME == filename_1 != filename_2 + + +def test_retrieveMediaFile(session_with_profile_loaded): + store_one_media_file() + result = ac.retrieveMediaFile(filename=FILENAME) + assert result == BASE64_DATA_1 + + +def test_getMediaFilesNames(session_with_profile_loaded): + filenames = store_two_media_files() + result = ac.getMediaFilesNames(pattern="_tes*.txt") + assert {*filenames} == {*result} + + +def test_deleteMediaFile(session_with_profile_loaded): + filename_1, filename_2 = store_two_media_files() + ac.deleteMediaFile(filename=filename_1) + assert ac.retrieveMediaFile(filename=filename_1) is False + assert ac.getMediaFilesNames(pattern="_tes*.txt") == [filename_2] + + +def test_getMediaDirPath(session_with_profile_loaded): + assert os.path.isdir(ac.getMediaDirPath()) diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100755 index 0000000..301b6c5 --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,77 @@ +import os + +import aqt +import pytest + +from conftest import ac, anki_connect_config_loaded, \ + set_up_test_deck_and_test_model_and_two_notes, \ + current_decks_and_models_etc_preserved, wait + + +# version is retrieved from config +def test_version(session_with_profile_loaded): + with anki_connect_config_loaded( + session=session_with_profile_loaded, + web_bind_port=0, + ): + assert ac.version() == 6 + + +def test_reloadCollection(setup): + ac.reloadCollection() + + +def test_apiReflect(setup): + result = ac.apiReflect( + scopes=["actions", "invalidType"], + actions=["apiReflect", "invalidMethod"] + ) + assert result == { + "scopes": ["actions"], + "actions": ["apiReflect"] + } + + +class TestProfiles: + def test_getProfiles(self, session_with_profile_loaded): + result = ac.getProfiles() + assert result == ["test_user"] + + # waiting a little while gets rid of the cryptic warning: + # Qt warning: QXcbConnection: XCB error: 8 (BadMatch), sequence: 658, + # resource id: 2097216, major code: 42 (SetInputFocus), minor code: 0 + def test_loadProfile(self, session_with_profile_loaded): + aqt.mw.unloadProfileAndShowProfileManager() + wait(0.1) + ac.loadProfile(name="test_user") + + +class TestExportImport: + # since Anki 2.1.50, exporting media for some wild reason + # will change the current working directory, which then gets removed. + # see `exporting.py`, ctrl-f `os.chdir(self.mediaDir)` + @pytest.fixture(autouse=True) + def current_working_directory_preserved(self): + cwd = os.getcwd() + yield + + try: + os.getcwd() + except FileNotFoundError: + os.chdir(cwd) + + def test_exportPackage(self, session_with_profile_loaded, setup): + filename = session_with_profile_loaded.base + "/export.apkg" + ac.exportPackage(deck="test_deck", path=filename) + + def test_importPackage(self, session_with_profile_loaded): + filename = session_with_profile_loaded.base + "/export.apkg" + + with current_decks_and_models_etc_preserved(): + set_up_test_deck_and_test_model_and_two_notes() + ac.exportPackage(deck="test_deck", path=filename) + + with current_decks_and_models_etc_preserved(): + assert "test_deck" not in ac.deckNames() + ac.importPackage(path=filename) + assert "test_deck" in ac.deckNames() diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100755 index 0000000..1cda36a --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,295 @@ +from conftest import ac +from plugin import anki_version + + +def test_modelNames(setup): + result = ac.modelNames() + assert "test_model" in result + + +def test_modelNamesAndIds(setup): + result = ac.modelNamesAndIds() + assert isinstance(result["test_model"], int) + + +def test_modelFieldNames(setup): + result = ac.modelFieldNames(modelName="test_model") + assert result == ["field1", "field2"] + + +def test_modelFieldDescriptions(setup): + result = ac.modelFieldDescriptions(modelName="test_model") + assert result == ["", ""] + + +def test_modelFieldFonts(setup): + result = ac.modelFieldFonts(modelName="test_model") + assert result == { + "field1": { + "font": "Arial", + "size": 20, + }, + "field2": { + "font": "Arial", + "size": 20, + }, + } + + +def test_modelFieldsOnTemplates(setup): + result = ac.modelFieldsOnTemplates(modelName="test_model") + assert result == { + "Card 1": [["field1"], ["field2"]], + "Card 2": [["field2"], ["field1"]], + } + + +class TestCreateModel: + createModel_kwargs = { + "modelName": "test_model_foo", + "inOrderFields": ["field1", "field2"], + "cardTemplates": [{"Front": "{{field1}}", "Back": "{{field2}}"}], + } + + def test_createModel_without_css(self, session_with_profile_loaded): + ac.createModel(**self.createModel_kwargs) + + def test_createModel_with_css(self, session_with_profile_loaded): + ac.createModel(**self.createModel_kwargs, css="* {}") + + +class TestStyling: + def test_modelStyling(self, setup): + result = ac.modelStyling(modelName="test_model") + assert result == {"css": "* {}"} + + def test_updateModelStyling(self, setup): + ac.updateModelStyling(model={ + "name": "test_model", + "css": "* {color: red;}" + }) + + assert ac.modelStyling(modelName="test_model") == { + "css": "* {color: red;}" + } + + +class TestModelTemplates: + def test_modelTemplates(self, setup): + result = ac.modelTemplates(modelName="test_model") + assert result == { + "Card 1": {"Front": "{{field1}}", "Back": "{{field2}}"}, + "Card 2": {"Front": "{{field2}}", "Back": "{{field1}}"} + } + + def test_updateModelTemplates(self, setup): + ac.updateModelTemplates(model={ + "name": "test_model", + "templates": {"Card 1": {"Front": "{{field1}}", "Back": "foo"}} + }) + + assert ac.modelTemplates(modelName="test_model") == { + "Card 1": {"Front": "{{field1}}", "Back": "foo"}, + "Card 2": {"Front": "{{field2}}", "Back": "{{field1}}"} + } + + +def test_findAndReplaceInModels(setup): + ac.findAndReplaceInModels( + modelName="test_model", + findText="}}", + replaceText="}}!", + front=True, + back=False, + css=False, + ) + + ac.findAndReplaceInModels( + modelName="test_model", + findText="}}", + replaceText="}}?", + front=True, + back=True, + css=False, + ) + + ac.findAndReplaceInModels( + modelName="test_model", + findText="}", + replaceText="color: blue;}", + front=False, + back=False, + css=True, + ) + + assert ac.modelTemplates(modelName="test_model") == { + "Card 1": {"Front": "{{field1}}?!", "Back": "{{field2}}?"}, + "Card 2": {"Front": "{{field2}}?!", "Back": "{{field1}}?"} + } + + assert ac.modelStyling(modelName="test_model") == { + "css": "* {color: blue;}" + } + + +class TestModelTemplates: + def test_modelTemplateRename(self, setup): + ac.modelTemplateRename( + modelName="test_model", + oldTemplateName="Card 1", + newTemplateName="Card 1 Renamed", + ) + + result = ac.modelTemplates(modelName="test_model") + assert result == { + "Card 1 Renamed": {"Front": "{{field1}}", "Back": "{{field2}}"}, + "Card 2": {"Front": "{{field2}}", "Back": "{{field1}}"} + } + + def test_modelTemplateReposition(self, setup): + # There currently isn't a way to test for order, so this is just a + # smoke test for now + ac.modelTemplateReposition( + modelName="test_model", + templateName="Card 1", + index=1, + ) + + def test_modelTemplateAdd(self, setup): + ac.modelTemplateAdd( + modelName="test_model", + template={ + "Name": "Card 3", + "Front": "{{field1}} Card 3", + "Back": "{{field2}}", + } + ) + + result = ac.modelTemplates(modelName="test_model") + assert result == { + "Card 1": {"Front": "{{field1}}", "Back": "{{field2}}"}, + "Card 2": {"Front": "{{field2}}", "Back": "{{field1}}"}, + "Card 3": {"Front": "{{field1}} Card 3", "Back": "{{field2}}"}, + } + + def test_modelTemplateRemove(self, setup): + ac.modelTemplateRemove( + modelName="test_model", + templateName="Card 2" + ) + + result = ac.modelTemplates(modelName="test_model") + assert result == { + "Card 1": {"Front": "{{field1}}", "Back": "{{field2}}"}, + } + + +class TestModelFieldNames: + def test_modelFieldRename(self, setup): + ac.modelFieldRename( + modelName="test_model", + oldFieldName="field1", + newFieldName="foo", + ) + + result = ac.modelFieldNames(modelName="test_model") + assert result == ["foo", "field2"] + + def test_modelFieldReposition(self, setup): + ac.modelFieldReposition( + modelName="test_model", + fieldName="field1", + index=2, + ) + + result = ac.modelFieldNames(modelName="test_model") + assert result == ["field2", "field1"] + + def test_modelFieldAdd(self, setup): + ac.modelFieldAdd( + modelName="test_model", + fieldName="Foo", + ) + + result = ac.modelFieldNames(modelName="test_model") + assert result == ["field1", "field2", "Foo"] + + def test_modelFieldAdd_with_index(self, setup): + ac.modelFieldAdd( + modelName="test_model", + fieldName="Foo", + index=1, + ) + + result = ac.modelFieldNames(modelName="test_model") + assert result == ["field1", "Foo", "field2"] + + def test_modelFieldRemove(self, setup): + # makes sure that the front template always has a field, + # and makes sure that the front template of the cards are not the same + ac.updateModelTemplates(model={ + "name": "test_model", + "templates": {"Card 1": {"Front": "{{field2}} {{field2}}", "Back": "foo"}} + }) + + ac.modelFieldRemove( + modelName="test_model", + fieldName="field1", + ) + + result = ac.modelFieldNames(modelName="test_model") + assert result == ["field2"] + + def test_modelFieldSetFont(self, setup): + ac.modelFieldSetFont( + modelName="test_model", + fieldName="field1", + font="Courier", + ) + + result = ac.modelFieldFonts(modelName="test_model") + assert result == { + "field1": { + "font": "Courier", + "size": 20, + }, + "field2": { + "font": "Arial", + "size": 20, + }, + } + + def test_modelFieldSetFontSize(self, setup): + ac.modelFieldSetFontSize( + modelName="test_model", + fieldName="field2", + fontSize=16, + ) + + result = ac.modelFieldFonts(modelName="test_model") + assert result == { + "field1": { + "font": "Arial", + "size": 20, + }, + "field2": { + "font": "Arial", + "size": 16, + }, + } + + def test_modelFieldSetDescription(self, setup): + set_desc = ac.modelFieldSetDescription( + modelName="test_model", + fieldName="field1", + description="test description", + ) + + result = ac.modelFieldDescriptions(modelName="test_model") + + if anki_version < (2, 1, 50): + assert not set_desc + assert result == ["", ""] + else: + assert set_desc + assert result == ["test description", ""] diff --git a/tests/test_notes.py b/tests/test_notes.py new file mode 100755 index 0000000..7558f99 --- /dev/null +++ b/tests/test_notes.py @@ -0,0 +1,163 @@ +import pytest +from anki.errors import NotFoundError # noqa + +from conftest import ac + + +def make_note(*, front="front1", allow_duplicates=False): + note = { + "deckName": "test_deck", + "modelName": "Basic", + "fields": {"Front": front, "Back": "back1"}, + "tags": ["tag1"], + } + + if allow_duplicates: + return {**note, "options": {"allowDuplicate": True}} + else: + return note + + +############################################################################## + + +class TestNoteAddition: + def test_addNote(self, setup): + result = ac.addNote(note=make_note()) + assert isinstance(result, int) + + def test_addNote_will_not_allow_duplicates_by_default(self, setup): + ac.addNote(make_note()) + with pytest.raises(Exception, match="it is a duplicate"): + ac.addNote(make_note()) + + def test_addNote_will_allow_duplicates_if_options_say_aye(self, setup): + ac.addNote(make_note()) + ac.addNote(make_note(allow_duplicates=True)) + + def test_addNotes(self, setup): + result = ac.addNotes(notes=[ + make_note(front="foo"), + make_note(front="bar"), + make_note(front="foo"), + ]) + + assert len(result) == 3 + assert isinstance(result[0], int) + assert isinstance(result[1], int) + assert result[2] is None + + def test_bug164(self, setup): + note = { + "deckName": "test_deck", + "modelName": "Basic", + "fields": {"Front": " Whitespace\n", "Back": ""}, + "options": {"allowDuplicate": False, "duplicateScope": "deck"} + } + + ac.addNote(note=note) + with pytest.raises(Exception, match="it is a duplicate"): + ac.addNote(note=note) + + +def test_notesInfo(setup): + result = ac.notesInfo(notes=[setup.note1_id]) + assert len(result) == 1 + assert result[0]["noteId"] == setup.note1_id + assert result[0]["tags"] == ["tag1"] + assert result[0]["fields"]["field1"]["value"] == "note1 field1" + + +class TestTags: + def test_addTags(self, setup): + ac.addTags(notes=[setup.note1_id], tags="tag2") + tags = ac.notesInfo(notes=[setup.note1_id])[0]["tags"] + assert {*tags} == {"tag1", "tag2"} + + def test_getTags(self, setup): + result = ac.getTags() + assert {*result} == {"tag1", "tag2"} + + def test_removeTags(self, setup): + ac.removeTags(notes=[setup.note2_id], tags="tag2") + assert ac.notesInfo(notes=[setup.note2_id])[0]["tags"] == [] + + def test_replaceTags(self, setup): + ac.replaceTags(notes=[setup.note1_id, 123], + tag_to_replace="tag1", replace_with_tag="foo") + notes_info = ac.notesInfo(notes=[setup.note1_id]) + assert notes_info[0]["tags"] == ["foo"] + + def test_replaceTagsInAllNotes(self, setup): + ac.replaceTagsInAllNotes(tag_to_replace="tag1", replace_with_tag="foo") + notes_info = ac.notesInfo(notes=[setup.note1_id]) + assert notes_info[0]["tags"] == ["foo"] + + def test_clearUnusedTags(self, setup): + ac.removeTags(notes=[setup.note2_id], tags="tag2") + ac.clearUnusedTags() + assert ac.getTags() == ["tag1"] + + def test_updateNoteTags_and_getNoteTags(self, setup): + ac.updateNoteTags(note=setup.note1_id, tags="footag") + assert ac.getNoteTags(note=setup.note1_id) == ["footag"] + ac.updateNoteTags(note=setup.note1_id, tags=["foo", "bar", "baz"]) + assert len(ac.getNoteTags(note=setup.note1_id)) == 3 + + +class TestUpdateNoteFields: + def test_updateNoteFields(self, setup): + new_fields = {"field1": "foo", "field2": "bar"} + good_note = {"id": setup.note1_id, "fields": new_fields} + ac.updateNoteFields(note=good_note) + notes_info = ac.notesInfo(notes=[setup.note1_id]) + assert notes_info[0]["fields"]["field2"]["value"] == "bar" + + def test_updateNoteFields_will_not_update_invalid_notes(self, setup): + bad_note = {"id": 123, "fields": make_note()["fields"]} + with pytest.raises(NotFoundError): + ac.updateNoteFields(note=bad_note) + + +class TestUpdateNote: + def test_updateNote(self, setup): + new_fields = {"field1": "frontbar", "field2": "backbar"} + new_tags = ["foobar"] + good_note = {"id": setup.note1_id, "fields": new_fields, "tags": new_tags} + ac.updateNote(note=good_note) + notes_info = ac.notesInfo(notes=[setup.note1_id]) + assert notes_info[0]["fields"]["field2"]["value"] == "backbar" + assert notes_info[0]["tags"] == ["foobar"] + + def test_updateNote_requires_either_fields_or_tags(self, setup): + with pytest.raises(Exception, match="ust provide"): + ac.updateNote(note={"id": setup.note1_id}) + + +class TestCanAddNotes: + foo_bar_notes = [make_note(front="foo"), make_note(front="bar")] + + def test_canAddNotes(self, setup): + result = ac.canAddNotes(notes=self.foo_bar_notes) + assert result == [True, True] + + def test_canAddNotes_will_not_add_duplicates_if_options_do_not_say_aye(self, setup): + ac.addNotes(notes=self.foo_bar_notes) + notes = [ + make_note(front="foo"), + make_note(front="baz"), + make_note(front="foo", allow_duplicates=True) + ] + result = ac.canAddNotes(notes=notes) + assert result == [False, True, True] + + +def test_findNotes(setup): + result = ac.findNotes(query="deck:test_deck") + assert {*result} == {setup.note1_id, setup.note2_id} + + +def test_deleteNotes(setup): + ac.deleteNotes(notes=[setup.note1_id, setup.note2_id]) + result = ac.findNotes(query="deck:test_deck") + assert result == [] diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..ed47947 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,198 @@ +import json +import multiprocessing +import time +import urllib.error +import urllib.request +from contextlib import contextmanager +from dataclasses import dataclass +from functools import partial + +import pytest +from pytest_anki._launch import anki_running # noqa +from pytest_anki._util import find_free_port # noqa + +from plugin import AnkiConnect +from tests.conftest import wait_until, \ + empty_anki_session_started, \ + anki_connect_config_loaded, \ + profile_created_and_loaded + + +@contextmanager +def function_running_in_a_process(context, function): + process = context.Process(target=function) + process.start() + + try: + yield process + finally: + process.join() + + +# todo stop the server? +@contextmanager +def anki_connect_web_server_started(): + plugin = AnkiConnect() + plugin.startWebServer() + yield plugin + + +@dataclass +class Client: + port: int + + @staticmethod + def make_request(action, **params): + return {"action": action, "params": params, "version": 6} + + def send_request(self, action, **params): + request_data = self.make_request(action, **params) + json_bytes = json.dumps(request_data).encode("utf-8") + return json.loads(self.send_bytes(json_bytes)) + + def send_bytes(self, bytes, headers={}): # noqa + request_url = f"http://localhost:{self.port}" + request = urllib.request.Request(request_url, bytes, headers) + response = urllib.request.urlopen(request).read() + return response + + def wait_for_web_server_to_come_live(self, at_most_seconds=30): + deadline = time.time() + at_most_seconds + + while time.time() < deadline: + try: + self.send_request("version") + return + except urllib.error.URLError: + time.sleep(0.01) + + raise Exception(f"Anki-Connect web server did not come live " + f"in {at_most_seconds} seconds") + + +# spawning requires a top-level function for pickling +def external_anki_entry_function(web_bind_port, exit_event): + with empty_anki_session_started() as session: + with anki_connect_config_loaded(session, web_bind_port): + with anki_connect_web_server_started(): + with profile_created_and_loaded(session): + wait_until(exit_event.is_set) + + +@contextmanager +def external_anki_running(process_run_method): + context = multiprocessing.get_context(process_run_method) + exit_event = context.Event() + web_bind_port = find_free_port() + function = partial(external_anki_entry_function, web_bind_port, exit_event) + + with function_running_in_a_process(context, function) as process: + client = Client(port=web_bind_port) + client.wait_for_web_server_to_come_live() + + try: + yield client + finally: + exit_event.set() + + assert process.exitcode == 0 + + +# if a Qt app was already launched in current process, +# launching a new Qt app, even from grounds up, fails or hangs. +# of course, this includes forked processes. therefore, +# * if launching without --forked, use the `spawn` process run method; +# * otherwise, use the `fork` method, as it is significantly faster. +# with --forked, each test has its fixtures assembled inside the fork, +# which means that when the test begins, Qt was never started in the fork. +@pytest.fixture(scope="module") +def external_anki(request): + """ + Runs Anki in an external process, with the plugin loaded and started. + On exit, neatly ends the process and makes sure its exit code is 0. + Yields a client that can send web request to the external process. + """ + with external_anki_running( + "fork" if request.config.option.forked else "spawn" + ) as client: + yield client + + +############################################################################## + + +def test_successful_request(external_anki): + response = external_anki.send_request("version") + assert response == {"error": None, "result": 6} + + +def test_can_handle_multiple_requests(external_anki): + assert external_anki.send_request("version") == {"error": None, "result": 6} + assert external_anki.send_request("version") == {"error": None, "result": 6} + + +def test_multi_request(external_anki): + version_request = Client.make_request("version") + response = external_anki.send_request("multi", actions=[version_request] * 3) + assert response == { + "error": None, + "result": [{"error": None, "result": 6}] * 3 + } + + +def test_request_with_empty_body_returns_version_banner(external_anki): + response = json.loads(external_anki.send_bytes(b"")) + assert response == { + "apiVersion": "AnkiConnect v.6" + } + + +def test_failing_request_due_to_bad_arguments(external_anki): + response = external_anki.send_request("addNote", bad="request") + assert response["result"] is None + assert "unexpected keyword argument" in response["error"] + + +def test_failing_request_due_to_anki_raising_exception(external_anki): + response = external_anki.send_request("suspend", cards=[-123]) + assert response["result"] is None + assert "Card was not found" in response["error"] + + +def test_failing_request_due_to_bad_encoding(external_anki): + response = json.loads(external_anki.send_bytes(b"\xe7\x8c")) + assert response["result"] is None + assert "can't decode" in response["error"] + + +def test_failing_request_due_to_bad_json(external_anki): + response = json.loads(external_anki.send_bytes(b'{1: 2}')) + assert response["result"] is None + assert "in double quotes" in response["error"] + + +def test_failing_request_due_to_json_root_not_being_an_object(external_anki): + response = json.loads(external_anki.send_bytes(b"1.2")) + assert response["result"] is None + assert "is not of type 'object'" in response["error"] + + +def test_failing_request_due_to_json_missing_wanted_properties(external_anki): + response = json.loads(external_anki.send_bytes(b"{}")) + assert response["result"] is None + assert "'action' is a required property" in response["error"] + + +def test_failing_request_due_to_json_properties_being_of_wrong_types(external_anki): + response = json.loads(external_anki.send_bytes(b'{"action": 1}')) + assert response["result"] is None + assert "1 is not of type 'string'" in response["error"] + + +def test_403_in_case_of_disallowed_origin(external_anki): + with pytest.raises(urllib.error.HTTPError, match="403"): # good request/json + json_bytes = json.dumps(Client.make_request("version")).encode("utf-8") + external_anki.send_bytes(json_bytes, headers={b"origin": b"foo"}) + + with pytest.raises(urllib.error.HTTPError, match="403"): # bad json + external_anki.send_bytes(b'{1: 2}', headers={b"origin": b"foo"}) diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100755 index 0000000..6cf16df --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,46 @@ +from conftest import ac + + +def test_getNumCardsReviewedToday(setup): + result = ac.getNumCardsReviewedToday() + assert isinstance(result, int) + + +def test_getNumCardsReviewedByDay(setup): + result = ac.getNumCardsReviewedByDay() + assert isinstance(result, list) + + +def test_getCollectionStatsHTML(setup): + result = ac.getCollectionStatsHTML() + assert isinstance(result, str) + + +class TestReviews: + def test_zero_reviews_for_a_new_deck(self, setup): + assert ac.cardReviews(deck="test_deck", startID=0) == [] + assert ac.getLatestReviewID(deck="test_deck") == 0 + + def test_some_reviews_for_a_reviewed_deck(self, setup): + ac.insertReviews(reviews=[ + (456, setup.card_ids[0], -1, 3, 4, -60, 2500, 6157, 0), + (789, setup.card_ids[1], -1, 1, -60, -60, 0, 4846, 0) + ]) + + assert len(ac.cardReviews(deck="test_deck", startID=0)) == 2 + assert ac.getLatestReviewID(deck="test_deck") == 789 + assert ac.getReviewsOfCards(cards=[setup.card_ids[0]]) == \ + { + setup.card_ids[0]: [ + { + "id": 456, + "usn": -1, + "ease": 3, + "ivl": 4, + "lastIvl": -60, + "factor": 2500, + "time": 6157, + "type": 0, + } + ] + }