Compare commits

...

33 Commits

Author SHA1 Message Date
kuuuube
98e0bb35fb Relax required anki version 2024-11-06 17:33:40 -08:00
kuuuube
a382fdfe85 Add support for flags 2024-11-05 18:29:10 -08:00
Philipp Matthias Schäfer
8bfb4049dc Replace duplicate code with call to addMediaFromNote 2024-10-08 20:26:20 -07:00
Philipp Matthias Schäfer
6eea9f9db4 Fix year component of Anki version 2024-10-06 17:51:24 -07:00
Philipp Matthias Schäfer
c89dbf2062 Make string an f-string
so the directives within the string will get evaluated.
2024-10-06 17:51:24 -07:00
Philipp Matthias Schäfer
ba67c52d9b Use non-deprecated snake case names of collection methods 2024-10-05 11:30:30 -07:00
Philipp Matthias Schäfer
cc027f8ab8 Replace calls to deprecated flush() with col.update_note/card
The changes passes skip_undo_entry to update_note/card, because the
implementation of the deprecated flush() method did so. Whether this is the
right decision for our use case was not checked/thought about.
2024-10-05 11:30:30 -07:00
Philipp Matthias Schäfer
dc96cdc76c Switch from deprecated names of anki/utils.py functions 2024-10-05 11:30:30 -07:00
Philipp Matthias Schäfer
6a2cc2dec1 Remove calls to deprecated (and empty) method autosave 2024-10-05 11:30:30 -07:00
Philipp Matthias Schäfer
172d1fb20f Require current Anki version 2024-10-05 11:30:30 -07:00
Diogo Cadavez
0514569621 added method to retrieve the active profile 2024-07-08 18:58:57 -07:00
Diogo Cadavez
81c39a2e42 added note modification time to notesIndo and created notesModeTime function 2024-07-08 18:58:57 -07:00
Richard Hajek
f52e0c2e24 addNotes reports errors, aborts on any error 2024-06-23 20:43:23 +09:00
Robert Irelan
aab9346deb sync: return error if full sync required or auth not set up
I run Anki on a headless machine (using Xvfb on Linux), and have clients
interact with it using Anki Connect. Currently, if a full sync is
required, I have no way of knowing about it from any error that Anki
Connect returns. It's easy to detect if a full sync would be required,
so return an error in this case.
2024-06-23 07:50:55 +09:00
jay-lee-code
7c171d6722 Added nextReviews field to cardsInfo, (similar to guiCurrentCard) 2024-06-09 11:30:57 -07:00
Tategoto Azarasi
306103c618 update README.md
Signed-off-by: Tategoto Azarasi <2724167997@qq.com>
2024-05-09 21:43:12 -07:00
Tategoto Azarasi
1c428c8627 remove try-except in updateNoteModel
Signed-off-by: Tategoto Azarasi <2724167997@qq.com>
2024-05-09 21:43:12 -07:00
Tategoto Azarasi
b77327fb00 add updateNoteModel
Signed-off-by: Tategoto Azarasi <2724167997@qq.com>
2024-05-09 21:43:12 -07:00
4837823bce Remove GitHub specific workflow 2024-04-01 21:59:57 -07:00
24cdeeb612 README fixes 2024-03-27 21:52:15 -07:00
00dffe579d Readme updates 2024-03-26 18:26:20 -07:00
Eloy Robillard
bbf271c5ac Return reason note cannot be added in new api endpoint 2024-02-26 20:34:24 -08:00
DegrangeM
2f7bc2e78e Fix requestPermission popup 2024-02-24 12:08:21 -08:00
aldoWan
d27f54a4fe Ensured forgotten cards are correctly synchronized with ankiweb by using ScheduleCardsAsNew
Resetting the database seemed not enough anymore, it's better to call the same function as ankiDroid is using: ScheduleCardsAsNew
2024-02-20 19:42:21 -08:00
Philipp Matthias Schäfer
2996476e03 Remove call to syncDelete
The method does no longer exist as of version 2.1.45, which is the minimum
supported version as of creation of this commit.
2024-01-21 13:52:23 -08:00
Philipp Matthias Schäfer
510f47fd08 Replace method calls of methods marked deprecated
There are more methods that have replacements according to the common pattern of
going from camelCase to snake_case, but that are not explicitly marked
deprecated.

Reference was tag 2.1.45 of Anki, as that is the minimally supported version of
anki-connect as of creating this commit.
2024-01-21 13:52:17 -08:00
Philipp Matthias Schäfer
977871257a Remove internal method stopEditing
The only method it calls, maybeReset, contains only a pass statement and has
thus never any effect.
2024-01-21 13:52:06 -08:00
AuroraWright
29260d6a00 Add reorderCards property to guiBrowse, add guiSelectNote 2024-01-18 19:59:38 -08:00
Kardia
a17c4e42da add readme entry for new findModels* apis 2024-01-04 12:33:02 -08:00
Kardia
50d062e6ca add apis to retrieve full model data by name and id 2024-01-04 12:33:02 -08:00
2de0798fc1 Fix guiEditNote returns error
Committing on behalf of Oleg Mitrofanov
2024-01-01 18:09:03 -08:00
2019ba75ed Rename CONTRIBUTING to MD 2023-12-31 11:01:59 -08:00
ab6349ec65 Add CONTRIBUTING file 2023-12-31 11:01:11 -08:00
7 changed files with 892 additions and 396 deletions

View File

@ -1,89 +0,0 @@
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

1
CONTRIBUTING.md Normal file
View File

@ -0,0 +1 @@
See https://foosoft.net/projects/contributing/ for details.

691
README.md
View File

@ -920,7 +920,7 @@ Search parameters are passed to Anki, check the docs for more information: https
#### `deleteDecks` #### `deleteDecks`
* Deletes decks with the given names. * Deletes decks with the given names.
The argument `cardsToo` *must* be specified and set to `true`. The argument `cardsToo` *must* be specified and set to `true`.
<details> <details>
@ -1226,6 +1226,10 @@ Search parameters are passed to Anki, check the docs for more information: https
* Invokes the *Card Browser* dialog and searches for a given query. Returns an array of identifiers of the cards that * 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). 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*.
<details> <details>
<summary><i>Sample request:</i></summary> <summary><i>Sample request:</i></summary>
@ -1234,7 +1238,11 @@ Search parameters are passed to Anki, check the docs for more information: https
"action": "guiBrowse", "action": "guiBrowse",
"version": 6, "version": 6,
"params": { "params": {
"query": "deck:current" "query": "deck:current",
"reorderCards": {
"order": "descending",
"columnId": "noteCrt"
}
} }
} }
``` ```
@ -1251,6 +1259,36 @@ Search parameters are passed to Anki, check the docs for more information: https
``` ```
</details> </details>
#### `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.
<details>
<summary><i>Sample request:</i></summary>
```json
{
"action": "guiSelectNote",
"version": 6,
"params": {
"note": 1494723142483
}
}
```
</details>
<details>
<summary><i>Sample result:</i></summary>
```json
{
"result": true,
"error": null
}
```
</details>
#### `guiSelectedNotes` #### `guiSelectedNotes`
* Finds the open instance of the *Card Browser* dialog and returns an array of identifiers of the notes that are * Finds the open instance of the *Card Browser* dialog and returns an array of identifiers of the notes that are
@ -1950,7 +1988,7 @@ Search parameters are passed to Anki, check the docs for more information: https
</details> </details>
<details> <details>
<summary><i>Samples results:</i></summary> <summary><i>Sample results:</i></summary>
```json ```json
{ {
@ -2019,11 +2057,11 @@ Search parameters are passed to Anki, check the docs for more information: https
```json ```json
{ {
"action": "apiReflect", "action": "apiReflect",
"version": 6,
"params": { "params": {
"scopes": ["actions", "invalidType"], "scopes": ["actions", "invalidType"],
"actions": ["apiReflect", "invalidMethod"] "actions": ["apiReflect", "invalidMethod"]
}, }
"version": 6
} }
``` ```
</details> </details>
@ -2094,6 +2132,33 @@ Search parameters are passed to Anki, check the docs for more information: https
``` ```
</details> </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` #### `loadProfile`
* Selects the profile specified in request. * Selects the profile specified in request.
@ -2104,10 +2169,10 @@ Search parameters are passed to Anki, check the docs for more information: https
```json ```json
{ {
"action": "loadProfile", "action": "loadProfile",
"version": 6,
"params": { "params": {
"name": "user1" "name": "user1"
}, }
"version": 6
} }
``` ```
</details> </details>
@ -2323,6 +2388,381 @@ Search parameters are passed to Anki, check the docs for more information: https
``` ```
</details> </details>
#### `findModelsById`
* Gets a list of models for the provided model IDs from the current user.
<details>
<summary><i>Sample request:</i></summary>
```json
{
"action": "findModelsById",
"version": 6,
"params": {
"modelIds": [1704387367119, 1704387398570]
}
}
```
</details>
<details>
<summary><i>Sample result:</i></summary>
```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<hr id=answer>\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<hr id=answer>\n\n{{Back}}",
"bqfmt": "",
"bafmt": "",
"did": null,
"bfont": "",
"bsize": 0,
"id": 1689886528158874152
},
{
"name": "Card 2",
"ord": 1,
"qfmt": "{{Back}}",
"afmt": "{{FrontSide}}\n\n<hr id=answer>\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
}
```
</details>
#### `findModelsByName`
* Gets a list of models for the provided model names from the current user.
<details>
<summary><i>Sample request:</i></summary>
```json
{
"action": "findModelsByName",
"version": 6,
"params": {
"modelNames": ["Basic", "Basic (and reversed card)"]
}
}
```
</details>
<details>
<summary><i>Sample result:</i></summary>
```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<hr id=answer>\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<hr id=answer>\n\n{{Back}}",
"bqfmt": "",
"bafmt": "",
"did": null,
"bfont": "",
"bsize": 0,
"id": 1689886528158874152
},
{
"name": "Card 2",
"ord": 1,
"qfmt": "{{Back}}",
"afmt": "{{FrontSide}}\n\n<hr id=answer>\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
}
```
</details>
#### `modelFieldNames` #### `modelFieldNames`
* Gets the complete list of field names for the provided model name. * Gets the complete list of field names for the provided model name.
@ -2455,13 +2895,13 @@ Search parameters are passed to Anki, check the docs for more information: https
#### `createModel` #### `createModel`
* Creates a new model to be used in Anki. User must provide the `modelName`, `inOrderFields` and `cardTemplates` to be * 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 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. card names will be `Card 1`, `Card 2`, and so on.
<details> <details>
<summary><i>Sample request</i></summary> <summary><i>Sample request:</i></summary>
```json ```json
{ {
@ -2485,7 +2925,7 @@ Search parameters are passed to Anki, check the docs for more information: https
</details> </details>
<details> <details>
<summary><i>Sample result</i></summary> <summary><i>Sample result:</i></summary>
```json ```json
{ {
@ -2575,7 +3015,7 @@ Search parameters are passed to Anki, check the docs for more information: https
</details> </details>
<details> <details>
<summary><i>Sample result</i></summary> <summary><i>Sample result:</i></summary>
```json ```json
{ {
@ -2613,7 +3053,7 @@ Search parameters are passed to Anki, check the docs for more information: https
</details> </details>
<details> <details>
<summary><i>Sample result</i></summary> <summary><i>Sample result:</i></summary>
```json ```json
{ {
@ -2773,7 +3213,7 @@ Search parameters are passed to Anki, check the docs for more information: https
```json ```json
{ {
"action": "modelTemplateRemove", "action": "modelTemplateReposition",
"version": 6, "version": 6,
"params": { "params": {
"modelName": "Basic", "modelName": "Basic",
@ -3026,7 +3466,7 @@ Search parameters are passed to Anki, check the docs for more information: https
```json ```json
{ {
"action": "modelFieldSetFont", "action": "modelFieldSetFontSize",
"version": 6, "version": 6,
"params": { "params": {
"modelName": "Basic", "modelName": "Basic",
@ -3179,55 +3619,36 @@ Search parameters are passed to Anki, check the docs for more information: https
#### `addNotes` #### `addNotes`
* Creates multiple notes using the given deck and model, with the provided field values and tags. Returns an array of * 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 (notes that could not be created will have a `null` identifier). Please see the identifiers of the created notes. In the event of any errors, all errors are gathered and returned.
documentation for `addNote` for an explanation of objects in the `notes` array. * Please see the documentation for `addNote` for an explanation of objects in the `notes` array.
<details> <details>
<summary><i>Sample request:</i></summary> <summary><i>Sample request:</i></summary>
```json ```json
{ {
"action": "addNotes", "action":"addNotes",
"version": 6, "version":6,
"params": { "params":{
"notes": [ "notes":[
{ {
"deckName": "Default", "deckName":"College::PluginDev",
"modelName": "Basic", "modelName":"non_existent_model",
"fields": { "fields":{
"Front": "front content", "Front":"front",
"Back": "back content" "Back":"bak"
},
"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> </details>
@ -3237,8 +3658,8 @@ Search parameters are passed to Anki, check the docs for more information: https
```json ```json
{ {
"result": [1496198395707, null], "result":null,
"error": null "error":"['model was not found: non_existent_model']"
} }
``` ```
</details> </details>
@ -3285,6 +3706,70 @@ Search parameters are passed to Anki, check the docs for more information: https
``` ```
</details> </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` #### `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 * 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
@ -3372,6 +3857,45 @@ 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> </details>
@ -3404,7 +3928,6 @@ Search parameters are passed to Anki, check the docs for more information: https
} }
} }
``` ```
</details> </details>
<details> <details>
@ -3416,7 +3939,6 @@ Search parameters are passed to Anki, check the docs for more information: https
"error": null "error": null
} }
``` ```
</details> </details>
#### `getNoteTags` #### `getNoteTags`
@ -3435,7 +3957,6 @@ Search parameters are passed to Anki, check the docs for more information: https
} }
} }
``` ```
</details> </details>
<details> <details>
@ -3447,7 +3968,6 @@ Search parameters are passed to Anki, check the docs for more information: https
"error": null "error": null
} }
``` ```
</details> </details>
#### `addTags` #### `addTags`
@ -3654,8 +4174,8 @@ Search parameters are passed to Anki, check the docs for more information: https
#### `notesInfo` #### `notesInfo`
* Returns a list of objects containing for each note ID the note fields, tags, note type and the cards belonging to * Returns a list of objects containing for each note ID the note fields, tags, note type, modification time,the cards belonging to
the note. the note and the profile where the note was created.
<details> <details>
<summary><i>Sample request:</i></summary> <summary><i>Sample request:</i></summary>
@ -3679,12 +4199,49 @@ Search parameters are passed to Anki, check the docs for more information: https
"result": [ "result": [
{ {
"noteId":1502298033753, "noteId":1502298033753,
"profile": "User_1",
"modelName": "Basic", "modelName": "Basic",
"tags":["tag","another_tag"], "tags":["tag","another_tag"],
"fields": { "fields": {
"Front": {"value": "front content", "order": 0}, "Front": {"value": "front content", "order": 0},
"Back": {"value": "back content", "order": 1} "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 "error": null
@ -3830,8 +4387,8 @@ Search parameters are passed to Anki, check the docs for more information: https
```json ```json
{ {
"error": null, "result": "<center> lots of HTML here </center>",
"result": "<center> lots of HTML here </center>" "error": null
} }
``` ```
</details> </details>

View File

@ -15,10 +15,11 @@
import aqt import aqt
required_anki_version = (23, 10, 0)
anki_version = tuple(int(segment) for segment in aqt.appVersion.split(".")) anki_version = tuple(int(segment) for segment in aqt.appVersion.split("."))
if anki_version < (2, 1, 45): if anki_version < required_anki_version:
raise Exception("Minimum Anki version supported: 2.1.45") raise Exception(f"Minimum Anki version supported: {required_anki_version[0]}.{required_anki_version[1]}.{required_anki_version[2]}")
import base64 import base64
import glob import glob
@ -41,6 +42,7 @@ from anki.exporting import AnkiPackageExporter
from anki.importing import AnkiPackageImporter from anki.importing import AnkiPackageImporter
from anki.notes import Note from anki.notes import Note
from anki.errors import NotFoundError from anki.errors import NotFoundError
from anki.scheduler.base import ScheduleCardsAsNew
from aqt.qt import Qt, QTimer, QMessageBox, QCheckBox from aqt.qt import Qt, QTimer, QMessageBox, QCheckBox
from .web import format_exception_reply, format_success_reply from .web import format_exception_reply, format_success_reply
@ -190,14 +192,14 @@ class AnkiConnect:
def getModel(self, modelName): def getModel(self, modelName):
model = self.collection().models.byName(modelName) model = self.collection().models.by_name(modelName)
if model is None: if model is None:
raise Exception('model was not found: {}'.format(modelName)) raise Exception('model was not found: {}'.format(modelName))
return model return model
def getField(self, model, fieldName): def getField(self, model, fieldName):
fieldMap = self.collection().models.fieldMap(model) fieldMap = self.collection().models.field_map(model)
if fieldName not in fieldMap: if fieldName not in fieldMap:
raise Exception('field was not found in {}: {}'.format(model['name'], fieldName)) raise Exception('field was not found in {}: {}'.format(model['name'], fieldName))
return fieldMap[fieldName][1] return fieldMap[fieldName][1]
@ -214,24 +216,19 @@ class AnkiConnect:
self.window().requireReset() self.window().requireReset()
def stopEditing(self):
if self.collection() is not None:
self.window().maybeReset()
def createNote(self, note): def createNote(self, note):
collection = self.collection() collection = self.collection()
model = collection.models.byName(note['modelName']) model = collection.models.by_name(note['modelName'])
if model is None: if model is None:
raise Exception('model was not found: {}'.format(note['modelName'])) raise Exception('model was not found: {}'.format(note['modelName']))
deck = collection.decks.byName(note['deckName']) deck = collection.decks.by_name(note['deckName'])
if deck is None: if deck is None:
raise Exception('deck was not found: {}'.format(note['deckName'])) raise Exception('deck was not found: {}'.format(note['deckName']))
ankiNote = anki.notes.Note(collection, model) ankiNote = anki.notes.Note(collection, model)
ankiNote.model()['did'] = deck['id'] ankiNote.note_type()['did'] = deck['id']
if 'tags' in note: if 'tags' in note:
ankiNote.tags = note['tags'] ankiNote.tags = note['tags']
@ -314,14 +311,14 @@ class AnkiConnect:
val = note.fields[0] val = note.fields[0]
if not val.strip(): if not val.strip():
return 1 return 1
csum = anki.utils.fieldChecksum(val) csum = anki.utils.field_checksum(val)
# Create dictionary of deck ids # Create dictionary of deck ids
dids = None dids = None
if duplicateScope == 'deck': if duplicateScope == 'deck':
did = deck['id'] did = deck['id']
if duplicateScopeDeckName is not None: if duplicateScopeDeckName is not None:
deck2 = collection.decks.byName(duplicateScopeDeckName) deck2 = collection.decks.by_name(duplicateScopeDeckName)
if deck2 is None: if deck2 is None:
# Invalid deck, so cannot be duplicate # Invalid deck, so cannot be duplicate
return 0 return 0
@ -362,13 +359,13 @@ class AnkiConnect:
def getCard(self, card_id: int) -> Card: def getCard(self, card_id: int) -> Card:
try: try:
return self.collection().getCard(card_id) return self.collection().get_card(card_id)
except NotFoundError: except NotFoundError:
self.raiseNotFoundError('Card was not found: {}'.format(card_id)) self.raiseNotFoundError('Card was not found: {}'.format(card_id))
def getNote(self, note_id: int) -> Note: def getNote(self, note_id: int) -> Note:
try: try:
return self.collection().getNote(note_id) return self.collection().get_note(note_id)
except NotFoundError: except NotFoundError:
self.raiseNotFoundError('Note was not found: {}'.format(note_id)) self.raiseNotFoundError('Note was not found: {}'.format(note_id))
@ -421,14 +418,20 @@ class AnkiConnect:
msg.setText('"{}" requests permission to use Anki through AnkiConnect. Do you want to give it access?'.format(origin)) 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.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.setWindowIcon(self.window().windowIcon())
msg.setIcon(QMessageBox.Question) msg.setIcon(QMessageBox.Icon.Question)
msg.setStandardButtons(QMessageBox.Yes|QMessageBox.No) msg.setStandardButtons(QMessageBox.StandardButton.Yes|QMessageBox.StandardButton.No)
msg.setDefaultButton(QMessageBox.No) msg.setDefaultButton(QMessageBox.StandardButton.No)
msg.setCheckBox(QCheckBox(text='Ignore further requests from "{}"'.format(origin), parent=msg)) msg.setCheckBox(QCheckBox(text='Ignore further requests from "{}"'.format(origin), parent=msg))
msg.setWindowFlags(Qt.WindowStaysOnTopHint) if hasattr(Qt, 'WindowStaysOnTopHint'):
pressedButton = msg.exec_() # 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.Yes: if pressedButton == QMessageBox.StandardButton.Yes:
config = aqt.mw.addonManager.getConfig(__name__) config = aqt.mw.addonManager.getConfig(__name__)
config["webCorsOriginList"] = util.setting('webCorsOriginList') config["webCorsOriginList"] = util.setting('webCorsOriginList')
config["webCorsOriginList"].append(origin) config["webCorsOriginList"].append(origin)
@ -440,7 +443,7 @@ class AnkiConnect:
} }
# if the origin isn't an empty string, the user clicks "No", and the ignore box is checked # if the origin isn't an empty string, the user clicks "No", and the ignore box is checked
elif origin and pressedButton == QMessageBox.No and msg.checkBox().isChecked(): elif origin and pressedButton == QMessageBox.StandardButton.No and msg.checkBox().isChecked():
config = aqt.mw.addonManager.getConfig(__name__) config = aqt.mw.addonManager.getConfig(__name__)
config["ignoreOriginList"] = util.setting('ignoreOriginList') config["ignoreOriginList"] = util.setting('ignoreOriginList')
config["ignoreOriginList"].append(origin) config["ignoreOriginList"].append(origin)
@ -454,7 +457,10 @@ class AnkiConnect:
@util.api() @util.api()
def getProfiles(self): def getProfiles(self):
return self.window().pm.profiles() return self.window().pm.profiles()
@util.api()
def getActiveProfile(self):
return self.window().pm.name
@util.api() @util.api()
def loadProfile(self, name): def loadProfile(self, name):
@ -486,7 +492,15 @@ class AnkiConnect:
@util.api() @util.api()
def sync(self): def sync(self):
self.window().onSync() 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() @util.api()
@ -517,7 +531,7 @@ class AnkiConnect:
@util.api() @util.api()
def deckNames(self): def deckNames(self):
return self.decks().allNames() return [x.name for x in self.decks().all_names_and_ids()]
@util.api() @util.api()
@ -545,13 +559,8 @@ class AnkiConnect:
@util.api() @util.api()
def createDeck(self, deck): def createDeck(self, deck):
try: self.startEditing()
self.startEditing() return self.decks().id(deck)
did = self.decks().id(deck)
finally:
self.stopEditing()
return did
@util.api() @util.api()
@ -559,7 +568,7 @@ class AnkiConnect:
self.startEditing() self.startEditing()
did = self.collection().decks.id(deck) did = self.collection().decks.id(deck)
mod = anki.utils.intTime() mod = anki.utils.int_time()
usn = self.collection().usn() usn = self.collection().usn()
# normal cards # normal cards
@ -569,7 +578,6 @@ class AnkiConnect:
# then move into new deck # then move into new deck
self.collection().db.execute('update cards set usn=?, mod=?, did=? where id in ' + scids, usn, mod, did) self.collection().db.execute('update cards set usn=?, mod=?, did=? where id in ' + scids, usn, mod, did)
self.stopEditing()
@util.api() @util.api()
@ -583,14 +591,11 @@ class AnkiConnect:
# this is dangerous, so let's raise our own exception # this is dangerous, so let's raise our own exception
raise Exception("Since Anki 2.1.28 it's not possible " raise Exception("Since Anki 2.1.28 it's not possible "
"to delete decks without deleting cards as well") "to delete decks without deleting cards as well")
try: self.startEditing()
self.startEditing() decks = filter(lambda d: d in self.deckNames(), decks)
decks = filter(lambda d: d in self.deckNames(), decks) for deck in decks:
for deck in decks: did = self.decks().id(deck)
did = self.decks().id(deck) self.decks().remove([did])
self.decks().rem(did, cardsToo=cardsToo)
finally:
self.stopEditing()
@util.api() @util.api()
@ -600,7 +605,7 @@ class AnkiConnect:
collection = self.collection() collection = self.collection()
did = collection.decks.id(deck) did = collection.decks.id(deck)
return collection.decks.confForDid(did) return collection.decks.config_dict_for_deck_id(did)
@util.api() @util.api()
@ -608,13 +613,13 @@ class AnkiConnect:
collection = self.collection() collection = self.collection()
config['id'] = str(config['id']) config['id'] = str(config['id'])
config['mod'] = anki.utils.intTime() config['mod'] = anki.utils.int_time()
config['usn'] = collection.usn() config['usn'] = collection.usn()
if int(config['id']) not in [c['id'] for c in collection.decks.all_config()]: if int(config['id']) not in [c['id'] for c in collection.decks.all_config()]:
return False return False
try: try:
collection.decks.save(config) collection.decks.save(config)
collection.decks.updateConf(config) collection.decks.update_config(config)
except: except:
return False return False
return True return True
@ -648,8 +653,8 @@ class AnkiConnect:
if configId not in [c['id'] for c in collection.decks.all_config()]: if configId not in [c['id'] for c in collection.decks.all_config()]:
return False return False
config = collection.decks.getConf(configId) config = collection.decks.get_config(configId)
return collection.decks.confId(name, config) return collection.decks.add_config_returning_id(name, config)
@util.api() @util.api()
@ -658,7 +663,7 @@ class AnkiConnect:
if int(configId) not in [c['id'] for c in collection.decks.all_config()]: if int(configId) not in [c['id'] for c in collection.decks.all_config()]:
return False return False
collection.decks.remConf(configId) collection.decks.remove_config(configId)
return True return True
@util.api() @util.api()
@ -722,10 +727,7 @@ class AnkiConnect:
@util.api() @util.api()
def deleteMediaFile(self, filename): def deleteMediaFile(self, filename):
try: self.media().trash_files([filename])
self.media().syncDelete(filename)
except AttributeError:
self.media().trash_files([filename])
@util.api() @util.api()
def getMediaDirPath(self): def getMediaDirPath(self):
@ -740,8 +742,6 @@ class AnkiConnect:
nCardsAdded = collection.addNote(ankiNote) nCardsAdded = collection.addNote(ankiNote)
if nCardsAdded < 1: if nCardsAdded < 1:
raise Exception('The field values you have provided would make an empty question on all cards.') raise Exception('The field values you have provided would make an empty question on all cards.')
collection.autosave()
self.stopEditing()
return ankiNote.id return ankiNote.id
@ -799,6 +799,17 @@ class AnkiConnect:
except: except:
return False 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() @util.api()
def updateNoteFields(self, note): def updateNoteFields(self, note):
@ -809,19 +820,9 @@ class AnkiConnect:
if name in ankiNote: if name in ankiNote:
ankiNote[name] = value ankiNote[name] = value
audioObjectOrList = note.get('audio') self.addMediaFromNote(ankiNote, note)
self.addMedia(ankiNote, audioObjectOrList, util.MediaType.Audio)
videoObjectOrList = note.get('video') self.collection().update_note(ankiNote, skip_undo_entry=True);
self.addMedia(ankiNote, videoObjectOrList, util.MediaType.Video)
pictureObjectOrList = note.get('picture')
self.addMedia(ankiNote, pictureObjectOrList, util.MediaType.Picture)
ankiNote.flush()
self.collection().autosave()
self.stopEditing()
@util.api() @util.api()
@ -836,6 +837,57 @@ class AnkiConnect:
if not updated: if not updated:
raise Exception('Must provide a "fields" or "tags" property.') 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() @util.api()
def updateNoteTags(self, note, tags): def updateNoteTags(self, note, tags):
@ -859,7 +911,6 @@ class AnkiConnect:
def addTags(self, notes, tags, add=True): def addTags(self, notes, tags, add=True):
self.startEditing() self.startEditing()
self.collection().tags.bulkAdd(notes, tags, add) self.collection().tags.bulkAdd(notes, tags, add)
self.stopEditing()
@util.api() @util.api()
@ -887,10 +938,10 @@ class AnkiConnect:
except NotFoundError: except NotFoundError:
continue continue
if note.hasTag(tag_to_replace): if note.has_tag(tag_to_replace):
note.delTag(tag_to_replace) note.remove_tag(tag_to_replace)
note.addTag(replace_with_tag) note.add_tag(replace_with_tag)
note.flush() self.collection().update_note(note, skip_undo_entry=True);
self.window().requireReset() self.window().requireReset()
self.window().progress.finish() self.window().progress.finish()
@ -904,10 +955,10 @@ class AnkiConnect:
collection = self.collection() collection = self.collection()
for nid in collection.db.list('select id from notes'): for nid in collection.db.list('select id from notes'):
note = self.getNote(nid) note = self.getNote(nid)
if note.hasTag(tag_to_replace): if note.has_tag(tag_to_replace):
note.delTag(tag_to_replace) note.remove_tag(tag_to_replace)
note.addTag(replace_with_tag) note.add_tag(replace_with_tag)
note.flush() self.collection().update_note(note, skip_undo_entry=True);
self.window().requireReset() self.window().requireReset()
self.window().progress.finish() self.window().progress.finish()
@ -926,7 +977,7 @@ class AnkiConnect:
couldSetEaseFactors.append(True) couldSetEaseFactors.append(True)
ankiCard.factor = easeFactors[i] ankiCard.factor = easeFactors[i]
ankiCard.flush() self.collection().update_card(ankiCard, skip_undo_entry=True)
return couldSetEaseFactors return couldSetEaseFactors
@ -956,7 +1007,7 @@ class AnkiConnect:
ankiCard = self.getCard(card) ankiCard = self.getCard(card)
for i, key in enumerate(keys): for i, key in enumerate(keys):
setattr(ankiCard, key, newValues[i]) setattr(ankiCard, key, newValues[i])
ankiCard.flush() self.collection().update_card(ankiCard, skip_undo_entry=True)
result.append(True) result.append(True)
except Exception as e: except Exception as e:
result.append([False, str(e)]) result.append([False, str(e)])
@ -993,7 +1044,6 @@ class AnkiConnect:
scheduler.suspendCards(cards) scheduler.suspendCards(cards)
else: else:
scheduler.unsuspendCards(cards) scheduler.unsuspendCards(cards)
self.stopEditing()
return True return True
@ -1055,7 +1105,7 @@ class AnkiConnect:
@util.api() @util.api()
def modelNames(self): def modelNames(self):
return self.collection().models.allNames() return [n.name for n in self.collection().models.all_names_and_ids()]
@util.api() @util.api()
@ -1065,7 +1115,7 @@ class AnkiConnect:
raise Exception('Must provide at least one field for inOrderFields') raise Exception('Must provide at least one field for inOrderFields')
if len(cardTemplates) == 0: if len(cardTemplates) == 0:
raise Exception('Must provide at least one card for cardTemplates') raise Exception('Must provide at least one card for cardTemplates')
if modelName in self.collection().models.allNames(): if modelName in [n.name for n in self.collection().models.all_names_and_ids()]:
raise Exception('Model name already exists') raise Exception('Model name already exists')
collection = self.collection() collection = self.collection()
@ -1078,7 +1128,7 @@ class AnkiConnect:
# Create fields and add them to Note # Create fields and add them to Note
for field in inOrderFields: for field in inOrderFields:
fm = mm.newField(field) fm = mm.new_field(field)
mm.addField(m, fm) mm.addField(m, fm)
# Add shared css to model if exists. Use default otherwise # Add shared css to model if exists. Use default otherwise
@ -1092,7 +1142,7 @@ class AnkiConnect:
if 'Name' in card: if 'Name' in card:
cardName = card['Name'] cardName = card['Name']
t = mm.newTemplate(cardName) t = mm.new_template(cardName)
cardCount += 1 cardCount += 1
t['qfmt'] = card['Front'] t['qfmt'] = card['Front']
t['afmt'] = card['Back'] t['afmt'] = card['Back']
@ -1106,11 +1156,33 @@ class AnkiConnect:
def modelNamesAndIds(self): def modelNamesAndIds(self):
models = {} models = {}
for model in self.modelNames(): for model in self.modelNames():
models[model] = int(self.collection().models.byName(model)['id']) models[model] = int(self.collection().models.by_name(model)['id'])
return models 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() @util.api()
def modelNameFromId(self, modelId): def modelNameFromId(self, modelId):
model = self.collection().models.get(modelId) model = self.collection().models.get(modelId)
@ -1122,7 +1194,7 @@ class AnkiConnect:
@util.api() @util.api()
def modelFieldNames(self, modelName): def modelFieldNames(self, modelName):
model = self.collection().models.byName(modelName) model = self.collection().models.by_name(modelName)
if model is None: if model is None:
raise Exception('model was not found: {}'.format(modelName)) raise Exception('model was not found: {}'.format(modelName))
else: else:
@ -1131,7 +1203,7 @@ class AnkiConnect:
@util.api() @util.api()
def modelFieldDescriptions(self, modelName): def modelFieldDescriptions(self, modelName):
model = self.collection().models.byName(modelName) model = self.collection().models.by_name(modelName)
if model is None: if model is None:
raise Exception('model was not found: {}'.format(modelName)) raise Exception('model was not found: {}'.format(modelName))
else: else:
@ -1159,7 +1231,7 @@ class AnkiConnect:
@util.api() @util.api()
def modelFieldsOnTemplates(self, modelName): def modelFieldsOnTemplates(self, modelName):
model = self.collection().models.byName(modelName) model = self.collection().models.by_name(modelName)
if model is None: if model is None:
raise Exception('model was not found: {}'.format(modelName)) raise Exception('model was not found: {}'.format(modelName))
@ -1190,7 +1262,7 @@ class AnkiConnect:
@util.api() @util.api()
def modelTemplates(self, modelName): def modelTemplates(self, modelName):
model = self.collection().models.byName(modelName) model = self.collection().models.by_name(modelName)
if model is None: if model is None:
raise Exception('model was not found: {}'.format(modelName)) raise Exception('model was not found: {}'.format(modelName))
@ -1203,7 +1275,7 @@ class AnkiConnect:
@util.api() @util.api()
def modelStyling(self, modelName): def modelStyling(self, modelName):
model = self.collection().models.byName(modelName) model = self.collection().models.by_name(modelName)
if model is None: if model is None:
raise Exception('model was not found: {}'.format(modelName)) raise Exception('model was not found: {}'.format(modelName))
@ -1213,7 +1285,7 @@ class AnkiConnect:
@util.api() @util.api()
def updateModelTemplates(self, model): def updateModelTemplates(self, model):
models = self.collection().models models = self.collection().models
ankiModel = models.byName(model['name']) ankiModel = models.by_name(model['name'])
if ankiModel is None: if ankiModel is None:
raise Exception('model was not found: {}'.format(model['name'])) raise Exception('model was not found: {}'.format(model['name']))
@ -1235,7 +1307,7 @@ class AnkiConnect:
@util.api() @util.api()
def updateModelStyling(self, model): def updateModelStyling(self, model):
models = self.collection().models models = self.collection().models
ankiModel = models.byName(model['name']) ankiModel = models.by_name(model['name'])
if ankiModel is None: if ankiModel is None:
raise Exception('model was not found: {}'.format(model['name'])) raise Exception('model was not found: {}'.format(model['name']))
@ -1249,13 +1321,13 @@ class AnkiConnect:
if not modelName: if not modelName:
ankiModel = self.collection().models.allNames() ankiModel = self.collection().models.allNames()
else: else:
model = self.collection().models.byName(modelName) model = self.collection().models.by_name(modelName)
if model is None: if model is None:
raise Exception('model was not found: {}'.format(modelName)) raise Exception('model was not found: {}'.format(modelName))
ankiModel = [modelName] ankiModel = [modelName]
updatedModels = 0 updatedModels = 0
for model in ankiModel: for model in ankiModel:
model = self.collection().models.byName(model) model = self.collection().models.by_name(model)
checkForText = False checkForText = False
if css and findText in model['css']: if css and findText in model['css']:
checkForText = True checkForText = True
@ -1344,7 +1416,7 @@ class AnkiConnect:
model = self.getModel(modelName) model = self.getModel(modelName)
field = self.getField(model, fieldName) field = self.getField(model, fieldName)
mm.repositionField(model, field, index) mm.reposition_field(model, field, index)
self.save_model(mm, model) self.save_model(mm, model)
@ -1355,16 +1427,16 @@ class AnkiConnect:
model = self.getModel(modelName) model = self.getModel(modelName)
# only adds the field if it doesn't already exist # only adds the field if it doesn't already exist
fieldMap = mm.fieldMap(model) fieldMap = mm.field_map(model)
if fieldName not in fieldMap: if fieldName not in fieldMap:
field = mm.newField(fieldName) field = mm.new_field(fieldName)
mm.addField(model, field) mm.addField(model, field)
# repositions, even if the field already exists # repositions, even if the field already exists
if index is not None: if index is not None:
fieldMap = mm.fieldMap(model) fieldMap = mm.field_map(model)
newField = fieldMap[fieldName][1] newField = fieldMap[fieldName][1]
mm.repositionField(model, newField, index) mm.reposition_field(model, newField, index)
self.save_model(mm, model) self.save_model(mm, model)
@ -1375,7 +1447,7 @@ class AnkiConnect:
model = self.getModel(modelName) model = self.getModel(modelName)
field = self.getField(model, fieldName) field = self.getField(model, fieldName)
mm.removeField(model, field) mm.remove_field(model, field)
self.save_model(mm, model) self.save_model(mm, model)
@ -1438,7 +1510,7 @@ class AnkiConnect:
if query is None: if query is None:
return [] return []
return list(map(int, self.collection().findNotes(query))) return list(map(int, self.collection().find_notes(query)))
@util.api() @util.api()
@ -1446,7 +1518,7 @@ class AnkiConnect:
if query is None: if query is None:
return [] return []
return list(map(int, self.collection().findCards(query))) return list(map(int, self.collection().find_cards(query)))
@util.api() @util.api()
@ -1455,13 +1527,15 @@ class AnkiConnect:
for cid in cards: for cid in cards:
try: try:
card = self.getCard(cid) card = self.getCard(cid)
model = card.model() model = card.note_type()
note = card.note() note = card.note()
fields = {} fields = {}
for info in model['flds']: for info in model['flds']:
order = info['ord'] order = info['ord']
name = info['name'] name = info['name']
fields[name] = {'value': note.fields[order], 'order': order} 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({ result.append({
'cardId': card.id, 'cardId': card.id,
@ -1485,6 +1559,8 @@ class AnkiConnect:
'lapses': card.lapses, 'lapses': card.lapses,
'left': card.left, 'left': card.left,
'mod': card.mod, 'mod': card.mod,
'nextReviews': list(nextReviews),
'flags': card.flags,
}) })
except NotFoundError: except NotFoundError:
# Anki will give a NotFoundError if the card ID does not exist. # Anki will give a NotFoundError if the card ID does not exist.
@ -1513,21 +1589,23 @@ class AnkiConnect:
result.append({}) result.append({})
return result return result
@util.api() @util.api()
def forgetCards(self, cards): def forgetCards(self, cards):
self.startEditing() self.startEditing()
scids = anki.utils.ids2str(cards) request = ScheduleCardsAsNew(
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) card_ids=cards,
self.stopEditing() log=True,
restore_position=True,
reset_counts=False,
context=None,
)
self.collection()._backend.schedule_cards_as_new(request)
@util.api() @util.api()
def relearnCards(self, cards): def relearnCards(self, cards):
self.startEditing() self.startEditing()
scids = anki.utils.ids2str(cards) scids = anki.utils.ids2str(cards)
self.collection().db.execute('update cards set type=3, queue=1 where id in ' + scids) self.collection().db.execute('update cards set type=3, queue=1 where id in ' + scids)
self.stopEditing()
@util.api() @util.api()
@ -1600,7 +1678,7 @@ class AnkiConnect:
for nid in notes: for nid in notes:
try: try:
note = self.getNote(nid) note = self.getNote(nid)
model = note.model() model = note.note_type()
fields = {} fields = {}
for info in model['flds']: for info in model['flds']:
@ -1610,9 +1688,11 @@ class AnkiConnect:
result.append({ result.append({
'noteId': note.id, 'noteId': note.id,
'profile': self.window().pm.name,
'tags' : note.tags, 'tags' : note.tags,
'fields': fields, 'fields': fields,
'modelName': model['name'], 'modelName': model['name'],
'mod': note.mod,
'cards': self.collection().db.list('select id from cards where nid = ? order by ord', note.id) 'cards': self.collection().db.list('select id from cards where nid = ? order by ord', note.id)
}) })
except NotFoundError: except NotFoundError:
@ -1624,20 +1704,34 @@ class AnkiConnect:
return result 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() @util.api()
def deleteNotes(self, notes): def deleteNotes(self, notes):
try: self.collection().remove_notes(notes)
self.collection().remNotes(notes)
finally:
self.stopEditing()
@util.api() @util.api()
def removeEmptyNotes(self): def removeEmptyNotes(self):
for model in self.collection().models.all(): for model in self.collection().models.all():
if self.collection().models.useCount(model) == 0: if self.collection().models.use_count(model) == 0:
self.collection().models.rem(model) self.collection().models.remove(model["id"])
self.window().requireReset() self.window().requireReset()
@ -1647,7 +1741,7 @@ class AnkiConnect:
@util.api() @util.api()
def guiBrowse(self, query=None): def guiBrowse(self, query=None, reorderCards=None):
browser = aqt.dialogs.open('Browser', self.window()) browser = aqt.dialogs.open('Browser', self.window())
browser.activateWindow() browser.activateWindow()
@ -1658,6 +1752,23 @@ class AnkiConnect:
else: else:
browser.onSearchActivated() 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) return self.findCards(query)
@ -1665,6 +1776,14 @@ class AnkiConnect:
def guiEditNote(self, note): def guiEditNote(self, note):
Edit.open_dialog_and_show_note_with_id(note) Edit.open_dialog_and_show_note_with_id(note)
@util.api()
def guiSelectNote(self, note):
(creator, instance) = aqt.dialogs._dialogs['Browser']
if instance is None:
return False
instance.table.clear_selection()
instance.table.select_single_card(note)
return True
@util.api() @util.api()
def guiSelectedNotes(self): def guiSelectedNotes(self):
@ -1678,18 +1797,18 @@ class AnkiConnect:
if note is not None: if note is not None:
collection = self.collection() collection = self.collection()
deck = collection.decks.byName(note['deckName']) deck = collection.decks.by_name(note['deckName'])
if deck is None: if deck is None:
raise Exception('deck was not found: {}'.format(note['deckName'])) raise Exception('deck was not found: {}'.format(note['deckName']))
collection.decks.select(deck['id']) collection.decks.select(deck['id'])
savedMid = deck.pop('mid', None) savedMid = deck.pop('mid', None)
model = collection.models.byName(note['modelName']) model = collection.models.by_name(note['modelName'])
if model is None: if model is None:
raise Exception('model was not found: {}'.format(note['modelName'])) raise Exception('model was not found: {}'.format(note['modelName']))
collection.models.setCurrent(model) collection.models.set_current(model)
collection.models.update(model) collection.models.update(model)
ankiNote = anki.notes.Note(collection, model) ankiNote = anki.notes.Note(collection, model)
@ -1748,7 +1867,7 @@ class AnkiConnect:
reviewer = self.reviewer() reviewer = self.reviewer()
card = reviewer.card card = reviewer.card
model = card.model() model = card.note_type()
note = card.note() note = card.note()
fields = {} fields = {}
@ -1829,7 +1948,7 @@ class AnkiConnect:
def guiDeckOverview(self, name): def guiDeckOverview(self, name):
collection = self.collection() collection = self.collection()
if collection is not None: if collection is not None:
deck = collection.decks.byName(name) deck = collection.decks.by_name(name)
if deck is not None: if deck is not None:
collection.decks.select(deck['id']) collection.decks.select(deck['id'])
self.window().onOverview() self.window().onOverview()
@ -1910,11 +2029,19 @@ class AnkiConnect:
@util.api() @util.api()
def addNotes(self, notes): def addNotes(self, notes):
results = [] results = []
errs = []
for note in notes: for note in notes:
try: try:
results.append(self.addNote(note)) results.append(self.addNote(note))
except: except Exception as e:
results.append(None) # 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 return results
@ -1927,12 +2054,20 @@ class AnkiConnect:
return results return results
@util.api()
def canAddNotesWithErrorDetail(self, notes):
results = []
for note in notes:
results.append(self.canAddNoteWithErrorDetail(note))
return results
@util.api() @util.api()
def exportPackage(self, deck, path, includeSched=False): def exportPackage(self, deck, path, includeSched=False):
collection = self.collection() collection = self.collection()
if collection is not None: if collection is not None:
deck = collection.decks.byName(deck) deck = collection.decks.by_name(deck)
if deck is not None: if deck is not None:
exporter = AnkiPackageExporter(collection) exporter = AnkiPackageExporter(collection)
exporter.did = deck['id'] exporter.did = deck['id']
@ -1952,10 +2087,8 @@ class AnkiConnect:
importer = AnkiPackageImporter(collection, path) importer = AnkiPackageImporter(collection, path)
importer.run() importer.run()
except: except:
self.stopEditing()
raise raise
else: else:
self.stopEditing()
return True return True
return False return False

View File

@ -2,8 +2,8 @@ import aqt
import aqt.editor import aqt.editor
import aqt.browser.previewer import aqt.browser.previewer
from aqt import gui_hooks from aqt import gui_hooks
from aqt.qt import QDialog, Qt, QKeySequence, QShortcut from aqt.qt import Qt, QKeySequence, QShortcut, QCloseEvent, QMainWindow
from aqt.utils import disable_help_button, restoreGeom, saveGeom, tooltip from aqt.utils import restoreGeom, saveGeom, tooltip
from anki.errors import NotFoundError from anki.errors import NotFoundError
from anki.consts import QUEUE_TYPE_SUSPENDED from anki.consts import QUEUE_TYPE_SUSPENDED
from anki.utils import ids2str from anki.utils import ids2str
@ -187,15 +187,13 @@ class Edit(aqt.editcurrent.EditCurrent):
# upon a request to open the dialog via `aqt.dialogs.open()`, # upon a request to open the dialog via `aqt.dialogs.open()`,
# the manager will call either the constructor or the `reopen` method # the manager will call either the constructor or the `reopen` method
def __init__(self, note): def __init__(self, note):
QDialog.__init__(self, None, Qt.WindowType.Window) QMainWindow.__init__(self, None, Qt.WindowType.Window)
aqt.mw.garbage_collect_on_dialog_finish(self)
self.form = aqt.forms.editcurrent.Ui_Dialog() self.form = aqt.forms.editcurrent.Ui_Dialog()
self.form.setupUi(self) self.form.setupUi(self)
self.setWindowTitle("Edit") self.setWindowTitle("Edit")
self.setMinimumWidth(250) self.setMinimumWidth(250)
self.setMinimumHeight(400) self.setMinimumHeight(400)
restoreGeom(self, self.dialog_geometry_tag) restoreGeom(self, self.dialog_geometry_tag)
disable_help_button(self)
self.form.buttonBox.setVisible(False) # hides the Close button bar self.form.buttonBox.setVisible(False) # hides the Close button bar
self.setup_editor_buttons() self.setup_editor_buttons()
@ -215,14 +213,16 @@ class Edit(aqt.editcurrent.EditCurrent):
self.show_note(note) self.show_note(note)
self.bring_to_foreground() self.bring_to_foreground()
def cleanup_and_close(self): def cleanup(self):
gui_hooks.editor_did_load_note.remove(self.editor_did_load_note) gui_hooks.editor_did_load_note.remove(self.editor_did_load_note)
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
self.editor.cleanup() self.editor.cleanup()
saveGeom(self, self.dialog_geometry_tag) saveGeom(self, self.dialog_geometry_tag)
aqt.dialogs.markClosed(self.dialog_registry_tag) aqt.dialogs.markClosed(self.dialog_registry_tag)
QDialog.reject(self)
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 # This method (mostly) solves (at least on my Windows 10 machine) three issues
# with window activation. Without this not even too hacky a fix, # with window activation. Without this not even too hacky a fix,
@ -283,7 +283,7 @@ class Edit(aqt.editcurrent.EditCurrent):
try: try:
self.note = history.get_last_note() self.note = history.get_last_note()
except IndexError: except IndexError:
self.cleanup_and_close() self.cleanup()
return return
self.show_note(self.note) self.show_note(self.note)

View File

@ -1,20 +0,0 @@
#!/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
View File

@ -1,86 +0,0 @@
# 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