Compare commits

..

No commits in common. "master" and "24.1.21.0" have entirely different histories.

5 changed files with 300 additions and 363 deletions

89
.github/workflows/tests.yml vendored Normal file
View 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
View File

@ -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>

View File

@ -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
View 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
View 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