Compare commits
No commits in common. "master" and "24.1.21.0" have entirely different histories.
89
.github/workflows/tests.yml
vendored
Normal file
89
.github/workflows/tests.yml
vendored
Normal file
@ -0,0 +1,89 @@
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request, workflow_dispatch]
|
||||
|
||||
jobs:
|
||||
run-tests:
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: Anki 2.1.45
|
||||
python: 3.8
|
||||
environment: py38-anki2.1.45
|
||||
- name: Anki 2.1.46
|
||||
python: 3.8
|
||||
environment: py38-anki2.1.46
|
||||
- name: Anki 2.1.47
|
||||
python: 3.8
|
||||
environment: py38-anki2.1.47
|
||||
- name: Anki 2.1.48
|
||||
python: 3.8
|
||||
environment: py38-anki2.1.48
|
||||
- name: Anki 2.1.49
|
||||
python: 3.8
|
||||
environment: py38-anki2.1.49
|
||||
- name: Anki 2.1.50 (Qt5)
|
||||
python: 3.9
|
||||
environment: py39-anki2.1.50-qt5
|
||||
- name: Anki 2.1.50 (Qt6)
|
||||
python: 3.9
|
||||
environment: py39-anki2.1.50-qt6
|
||||
- name: Anki 2.1.51 (Qt5)
|
||||
python: 3.9
|
||||
environment: py39-anki2.1.51-qt5
|
||||
- name: Anki 2.1.51 (Qt6)
|
||||
python: 3.9
|
||||
environment: py39-anki2.1.51-qt6
|
||||
- name: Anki 2.1.52 (Qt5)
|
||||
python: 3.9
|
||||
environment: py39-anki2.1.52-qt5
|
||||
- name: Anki 2.1.52 (Qt6)
|
||||
python: 3.9
|
||||
environment: py39-anki2.1.52-qt6
|
||||
- name: Anki 2.1.53 (Qt5)
|
||||
python: 3.9
|
||||
environment: py39-anki2.1.53-qt5
|
||||
- name: Anki 2.1.53 (Qt6)
|
||||
python: 3.9
|
||||
environment: py39-anki2.1.53-qt6
|
||||
- name: Anki 2.1.54 (Qt5)
|
||||
python: 3.9
|
||||
environment: py39-anki2.1.54-qt5
|
||||
- name: Anki 2.1.54 (Qt6)
|
||||
python: 3.9
|
||||
environment: py39-anki2.1.54-qt6
|
||||
- name: Anki 2.1.55 (Qt5)
|
||||
python: 3.9
|
||||
environment: py39-anki2.1.55-qt5
|
||||
- name: Anki 2.1.55 (Qt6)
|
||||
python: 3.9
|
||||
environment: py39-anki2.1.55-qt6
|
||||
- name: Anki 2.1.56 (Qt5)
|
||||
python: 3.9
|
||||
environment: py39-anki2.1.56-qt5
|
||||
- name: Anki 2.1.56 (Qt6)
|
||||
python: 3.9
|
||||
environment: py39-anki2.1.56-qt6
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pyqt5-dev-tools xvfb jq
|
||||
|
||||
- name: Setup Python ${{ matrix.python }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
|
||||
- name: Install tox
|
||||
run: pip install 'tox==3.28.0'
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Run tests
|
||||
run: tox -vvve ${{ matrix.environment }} -- --forked --verbose
|
278
README.md
278
README.md
@ -1262,7 +1262,7 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
#### `guiSelectNote`
|
||||
|
||||
* Finds the open instance of the *Card Browser* dialog and selects a note given a note identifier.
|
||||
Returns `true` if the *Card Browser* is open, `false` otherwise.
|
||||
Returns `True` if the *Card Browser* is open, `False` otherwise.
|
||||
|
||||
<details>
|
||||
<summary><i>Sample request:</i></summary>
|
||||
@ -1988,7 +1988,7 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><i>Sample results:</i></summary>
|
||||
<summary><i>Samples results:</i></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
@ -2057,11 +2057,11 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
```json
|
||||
{
|
||||
"action": "apiReflect",
|
||||
"version": 6,
|
||||
"params": {
|
||||
"scopes": ["actions", "invalidType"],
|
||||
"actions": ["apiReflect", "invalidMethod"]
|
||||
}
|
||||
},
|
||||
"version": 6
|
||||
}
|
||||
```
|
||||
</details>
|
||||
@ -2132,33 +2132,6 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
```
|
||||
</details>
|
||||
|
||||
#### `getActiveProfile`
|
||||
|
||||
* Retrieve the active profile.
|
||||
|
||||
<details>
|
||||
<summary><i>Sample request:</i></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "getActiveProfile",
|
||||
"version": 6
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><i>Sample result:</i></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"result": "User 1",
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
#### `loadProfile`
|
||||
|
||||
* Selects the profile specified in request.
|
||||
@ -2169,10 +2142,10 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
```json
|
||||
{
|
||||
"action": "loadProfile",
|
||||
"version": 6,
|
||||
"params": {
|
||||
"name": "user1"
|
||||
}
|
||||
},
|
||||
"version": 6
|
||||
}
|
||||
```
|
||||
</details>
|
||||
@ -2400,7 +2373,7 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
"action": "findModelsById",
|
||||
"version": 6,
|
||||
"params": {
|
||||
"modelIds": [1704387367119, 1704387398570]
|
||||
"modelIds": [ 1704387367119, 1704387398570 ]
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -2895,13 +2868,13 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
#### `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.
|
||||
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.
|
||||
|
||||
<details>
|
||||
<summary><i>Sample request:</i></summary>
|
||||
<summary><i>Sample request</i></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
@ -2925,7 +2898,7 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><i>Sample result:</i></summary>
|
||||
<summary><i>Sample result</i></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
@ -3015,7 +2988,7 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><i>Sample result:</i></summary>
|
||||
<summary><i>Sample result</i></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
@ -3053,7 +3026,7 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><i>Sample result:</i></summary>
|
||||
<summary><i>Sample result</i></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
@ -3213,7 +3186,7 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "modelTemplateReposition",
|
||||
"action": "modelTemplateRemove",
|
||||
"version": 6,
|
||||
"params": {
|
||||
"modelName": "Basic",
|
||||
@ -3466,7 +3439,7 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "modelFieldSetFontSize",
|
||||
"action": "modelFieldSetFont",
|
||||
"version": 6,
|
||||
"params": {
|
||||
"modelName": "Basic",
|
||||
@ -3619,36 +3592,55 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
#### `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.
|
||||
identifiers of the created notes (notes that could not be created will have a `null` identifier). Please see the
|
||||
documentation for `addNote` for an explanation of objects in the `notes` array.
|
||||
|
||||
<details>
|
||||
<summary><i>Sample request:</i></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"action":"addNotes",
|
||||
"version":6,
|
||||
"params":{
|
||||
"notes":[
|
||||
{
|
||||
"deckName":"College::PluginDev",
|
||||
"modelName":"non_existent_model",
|
||||
"fields":{
|
||||
"Front":"front",
|
||||
"Back":"bak"
|
||||
"action": "addNotes",
|
||||
"version": 6,
|
||||
"params": {
|
||||
"notes": [
|
||||
{
|
||||
"deckName": "Default",
|
||||
"modelName": "Basic",
|
||||
"fields": {
|
||||
"Front": "front content",
|
||||
"Back": "back content"
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"deckName":"College::PluginDev",
|
||||
"modelName":"Basic",
|
||||
"fields":{
|
||||
"Front":"front",
|
||||
"Back":"bak"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
@ -3658,8 +3650,8 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
|
||||
```json
|
||||
{
|
||||
"result":null,
|
||||
"error":"['model was not found: non_existent_model']"
|
||||
"result": [1496198395707, null],
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
</details>
|
||||
@ -3706,70 +3698,6 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
```
|
||||
</details>
|
||||
|
||||
#### `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.
|
||||
|
||||
<details>
|
||||
<summary><i>Sample request:</i></summary>
|
||||
|
||||
```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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><i>Sample result:</i></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"result": [
|
||||
{
|
||||
"canAdd": false,
|
||||
"error": "cannot create note because it is a duplicate"
|
||||
},
|
||||
{
|
||||
"canAdd": true
|
||||
}
|
||||
],
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
#### `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
|
||||
@ -3857,45 +3785,6 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><i>Sample result:</i></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"result": null,
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
#### `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.
|
||||
|
||||
<details>
|
||||
<summary><i>Sample request:</i></summary>
|
||||
|
||||
```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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@ -3928,6 +3817,7 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@ -3939,6 +3829,7 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### `getNoteTags`
|
||||
@ -3957,6 +3848,7 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@ -3968,6 +3860,7 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### `addTags`
|
||||
@ -4174,8 +4067,8 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
|
||||
#### `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.
|
||||
* Returns a list of objects containing for each note ID the note fields, tags, note type and the cards belonging to
|
||||
the note.
|
||||
|
||||
<details>
|
||||
<summary><i>Sample request:</i></summary>
|
||||
@ -4199,49 +4092,12 @@ Search parameters are passed to Anki, check the docs for more information: https
|
||||
"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
|
||||
}
|
||||
```
|
||||
</details>
|
||||
s
|
||||
#### `notesModTime`
|
||||
|
||||
* Returns a list of objects containings for each note ID the modification time.
|
||||
|
||||
<details>
|
||||
<summary><i>Sample request:</i></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "notesModTime",
|
||||
"version": 6,
|
||||
"params": {
|
||||
"notes": [1502298033753]
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><i>Sample result:</i></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"result": [
|
||||
{
|
||||
"noteId": 1498938915662,
|
||||
"mod": 1629454092
|
||||
}
|
||||
}
|
||||
],
|
||||
"error": null
|
||||
@ -4387,8 +4243,8 @@ s
|
||||
|
||||
```json
|
||||
{
|
||||
"result": "<center> lots of HTML here </center>",
|
||||
"error": null
|
||||
"error": null,
|
||||
"result": "<center> lots of HTML here </center>"
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
@ -15,11 +15,10 @@
|
||||
|
||||
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]}")
|
||||
if anki_version < (2, 1, 45):
|
||||
raise Exception("Minimum Anki version supported: 2.1.45")
|
||||
|
||||
import base64
|
||||
import glob
|
||||
@ -42,7 +41,6 @@ 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
|
||||
@ -311,7 +309,7 @@ class AnkiConnect:
|
||||
val = note.fields[0]
|
||||
if not val.strip():
|
||||
return 1
|
||||
csum = anki.utils.field_checksum(val)
|
||||
csum = anki.utils.fieldChecksum(val)
|
||||
|
||||
# Create dictionary of deck ids
|
||||
dids = None
|
||||
@ -359,13 +357,13 @@ class AnkiConnect:
|
||||
|
||||
def getCard(self, card_id: int) -> Card:
|
||||
try:
|
||||
return self.collection().get_card(card_id)
|
||||
return self.collection().getCard(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)
|
||||
return self.collection().getNote(note_id)
|
||||
except NotFoundError:
|
||||
self.raiseNotFoundError('Note was not found: {}'.format(note_id))
|
||||
|
||||
@ -418,20 +416,14 @@ class AnkiConnect:
|
||||
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.setIcon(QMessageBox.Question)
|
||||
msg.setStandardButtons(QMessageBox.Yes|QMessageBox.No)
|
||||
msg.setDefaultButton(QMessageBox.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()
|
||||
msg.setWindowFlags(Qt.WindowStaysOnTopHint)
|
||||
pressedButton = msg.exec_()
|
||||
|
||||
if pressedButton == QMessageBox.StandardButton.Yes:
|
||||
if pressedButton == QMessageBox.Yes:
|
||||
config = aqt.mw.addonManager.getConfig(__name__)
|
||||
config["webCorsOriginList"] = util.setting('webCorsOriginList')
|
||||
config["webCorsOriginList"].append(origin)
|
||||
@ -443,7 +435,7 @@ class AnkiConnect:
|
||||
}
|
||||
|
||||
# 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():
|
||||
elif origin and pressedButton == QMessageBox.No and msg.checkBox().isChecked():
|
||||
config = aqt.mw.addonManager.getConfig(__name__)
|
||||
config["ignoreOriginList"] = util.setting('ignoreOriginList')
|
||||
config["ignoreOriginList"].append(origin)
|
||||
@ -457,10 +449,7 @@ class AnkiConnect:
|
||||
@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):
|
||||
@ -492,15 +481,7 @@ class AnkiConnect:
|
||||
|
||||
@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()
|
||||
self.window().onSync()
|
||||
|
||||
|
||||
@util.api()
|
||||
@ -568,7 +549,7 @@ class AnkiConnect:
|
||||
self.startEditing()
|
||||
|
||||
did = self.collection().decks.id(deck)
|
||||
mod = anki.utils.int_time()
|
||||
mod = anki.utils.intTime()
|
||||
usn = self.collection().usn()
|
||||
|
||||
# normal cards
|
||||
@ -613,7 +594,7 @@ class AnkiConnect:
|
||||
collection = self.collection()
|
||||
|
||||
config['id'] = str(config['id'])
|
||||
config['mod'] = anki.utils.int_time()
|
||||
config['mod'] = anki.utils.intTime()
|
||||
config['usn'] = collection.usn()
|
||||
if int(config['id']) not in [c['id'] for c in collection.decks.all_config()]:
|
||||
return False
|
||||
@ -742,6 +723,7 @@ class AnkiConnect:
|
||||
nCardsAdded = collection.addNote(ankiNote)
|
||||
if nCardsAdded < 1:
|
||||
raise Exception('The field values you have provided would make an empty question on all cards.')
|
||||
collection.autosave()
|
||||
|
||||
return ankiNote.id
|
||||
|
||||
@ -799,17 +781,6 @@ class AnkiConnect:
|
||||
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):
|
||||
@ -820,9 +791,18 @@ class AnkiConnect:
|
||||
if name in ankiNote:
|
||||
ankiNote[name] = value
|
||||
|
||||
self.addMediaFromNote(ankiNote, note)
|
||||
audioObjectOrList = note.get('audio')
|
||||
self.addMedia(ankiNote, audioObjectOrList, util.MediaType.Audio)
|
||||
|
||||
self.collection().update_note(ankiNote, skip_undo_entry=True);
|
||||
videoObjectOrList = note.get('video')
|
||||
self.addMedia(ankiNote, videoObjectOrList, util.MediaType.Video)
|
||||
|
||||
pictureObjectOrList = note.get('picture')
|
||||
self.addMedia(ankiNote, pictureObjectOrList, util.MediaType.Picture)
|
||||
|
||||
ankiNote.flush()
|
||||
|
||||
self.collection().autosave()
|
||||
|
||||
|
||||
@util.api()
|
||||
@ -837,57 +817,6 @@ class AnkiConnect:
|
||||
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):
|
||||
@ -941,7 +870,7 @@ class AnkiConnect:
|
||||
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);
|
||||
note.flush()
|
||||
|
||||
self.window().requireReset()
|
||||
self.window().progress.finish()
|
||||
@ -958,7 +887,7 @@ class AnkiConnect:
|
||||
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);
|
||||
note.flush()
|
||||
|
||||
self.window().requireReset()
|
||||
self.window().progress.finish()
|
||||
@ -977,7 +906,7 @@ class AnkiConnect:
|
||||
|
||||
couldSetEaseFactors.append(True)
|
||||
ankiCard.factor = easeFactors[i]
|
||||
self.collection().update_card(ankiCard, skip_undo_entry=True)
|
||||
ankiCard.flush()
|
||||
|
||||
return couldSetEaseFactors
|
||||
|
||||
@ -1007,7 +936,7 @@ class AnkiConnect:
|
||||
ankiCard = self.getCard(card)
|
||||
for i, key in enumerate(keys):
|
||||
setattr(ankiCard, key, newValues[i])
|
||||
self.collection().update_card(ankiCard, skip_undo_entry=True)
|
||||
ankiCard.flush()
|
||||
result.append(True)
|
||||
except Exception as e:
|
||||
result.append([False, str(e)])
|
||||
@ -1534,8 +1463,6 @@ class AnkiConnect:
|
||||
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,
|
||||
@ -1559,8 +1486,6 @@ class AnkiConnect:
|
||||
'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.
|
||||
@ -1589,17 +1514,13 @@ class AnkiConnect:
|
||||
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)
|
||||
scids = anki.utils.ids2str(cards)
|
||||
self.collection().db.execute('update cards set type=0, queue=0, left=0, ivl=0, due=0, odue=0, factor=0 where id in ' + scids)
|
||||
|
||||
|
||||
@util.api()
|
||||
def relearnCards(self, cards):
|
||||
@ -1688,11 +1609,9 @@ class AnkiConnect:
|
||||
|
||||
result.append({
|
||||
'noteId': note.id,
|
||||
'profile': self.window().pm.name,
|
||||
'tags' : note.tags,
|
||||
'fields': fields,
|
||||
'modelName': model['name'],
|
||||
'mod': note.mod,
|
||||
'cards': self.collection().db.list('select id from cards where nid = ? order by ord', note.id)
|
||||
})
|
||||
except NotFoundError:
|
||||
@ -1704,23 +1623,6 @@ class AnkiConnect:
|
||||
|
||||
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):
|
||||
@ -2029,19 +1931,11 @@ class AnkiConnect:
|
||||
@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))
|
||||
except:
|
||||
results.append(None)
|
||||
|
||||
return results
|
||||
|
||||
@ -2054,14 +1948,6 @@ class AnkiConnect:
|
||||
|
||||
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):
|
||||
|
20
tox-install-command
Normal file
20
tox-install-command
Normal file
@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -eux
|
||||
trap '[[ -v SERVER_PID ]] && pkill -P $SERVER_PID' EXIT
|
||||
print_first_group() { perl -snle 'm/$re/; print $1; exit 0' -- -re="$1"; }
|
||||
|
||||
envname="$1"
|
||||
toxworkdir="$2"
|
||||
packages=("${@:3}")
|
||||
|
||||
version=$(print_first_group 'anki([\d\.a-z]+)' <<< "$envname")
|
||||
upload_time=$(curl https://pypi.org/pypi/anki/json \
|
||||
| jq --arg v "$version" -r '.releases[$v][0].upload_time_iso_8601')
|
||||
cutoff_time=$(date --utc -d "$upload_time +1 hour" '+%Y-%m-%dT%H:%M:%S')
|
||||
|
||||
coproc SERVER { "$toxworkdir"/.tox/bin/python -um pypi_timemachine "$cutoff_time"; }
|
||||
index_url=$(print_first_group '(http\S+)' <&"${SERVER[0]}")
|
||||
|
||||
python -m pip install --index-url "$index_url" "anki==$version" "$AQT==$version"
|
||||
python -m pip install "${packages[@]}"
|
86
tox.ini
Normal file
86
tox.ini
Normal file
@ -0,0 +1,86 @@
|
||||
# For testing, you will need:
|
||||
# * PyQt5 dev tools
|
||||
# * tox
|
||||
# * X virtual framebuffer--to test without GUI
|
||||
#
|
||||
# Install these by running:
|
||||
# $ sudo apt install pyqt5-dev-tools xvfb
|
||||
# $ python3 -m pip install --user --upgrade tox
|
||||
#
|
||||
# Then, to run tests against multiple anki versions:
|
||||
# $ tox
|
||||
#
|
||||
# To run tests slightly less safely, but faster:
|
||||
# $ tox -- --no-tear-down-profile-after-each-test
|
||||
#
|
||||
# To run tests more safely, but *much* slower:
|
||||
# $ tox -- --forked
|
||||
|
||||
# Test tool cheat sheet:
|
||||
# * Test several environments in parallel:
|
||||
# $ tox -p auto
|
||||
#
|
||||
# * To activate one of the test environments:
|
||||
# $ source .tox/py38-anki49/bin/activate
|
||||
#
|
||||
# * Stop on first failure:
|
||||
# $ xvfb-run python -m pytest -x
|
||||
#
|
||||
# * See stdout/stderr (doesn't work with --forked!):
|
||||
# $ xvfb-run python -m pytest -s
|
||||
#
|
||||
# * Run some specific tests:
|
||||
# $ xvfb-run python -m pytest -k "test_cards.py or test_guiBrowse"
|
||||
#
|
||||
# * To run with visible GUI in WSL2
|
||||
# (Make sure to disable access control in your X server, such as VcXsrv):
|
||||
# $ DISPLAY=$(ip route list default | awk '{print $3}'):0 python -m pytest
|
||||
#
|
||||
# * Environmental variables of interest:
|
||||
# LIBGL_ALWAYS_INDIRECT=1
|
||||
# QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu"
|
||||
# QT_DEBUG_PLUGINS=1
|
||||
# ANKIDEV=1
|
||||
|
||||
# Note: pypi packages anki and aqt do not pin their dependencies.
|
||||
# To tests against historically accurate dependencies, we use a “time machine”
|
||||
# that prevents pip from using packages that were uploaded after a specified date.
|
||||
|
||||
[tox]
|
||||
minversion = 3.24
|
||||
skipsdist = true
|
||||
skip_install = true
|
||||
requires =
|
||||
pypi-timemachine
|
||||
envlist =
|
||||
py38-anki2.1.{45,46,47,48,49}
|
||||
py39-anki2.1.{50,51,52,53,54,55,56}-qt{5,6}
|
||||
|
||||
[testenv:.tox]
|
||||
install_command =
|
||||
python -m pip install {packages}
|
||||
|
||||
[testenv]
|
||||
install_command =
|
||||
bash tox-install-command {envname} {toxworkdir} {packages}
|
||||
|
||||
commands =
|
||||
env HOME={envtmpdir}/home xvfb-run python -m pytest {posargs}
|
||||
|
||||
setenv =
|
||||
DISABLE_QT5_COMPAT=1
|
||||
QTWEBENGINE_CHROMIUM_FLAGS=--no-sandbox
|
||||
|
||||
!qt{5,6}: AQT=aqt
|
||||
qt5: AQT=aqt[qt5]
|
||||
qt6: AQT=aqt[qt6]
|
||||
|
||||
allowlist_externals =
|
||||
bash
|
||||
env
|
||||
xvfb-run
|
||||
|
||||
deps =
|
||||
pytest==7.1.1
|
||||
pytest-forked==1.4.0
|
||||
pytest-anki @ git+https://github.com/oakkitten/pytest-anki.git@a0d27aa5
|
Loading…
Reference in New Issue
Block a user