Compare commits

...

12 Commits

Author SHA1 Message Date
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
2 changed files with 150 additions and 79 deletions

139
README.md
View File

@ -2132,6 +2132,33 @@ 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.
@ -3592,55 +3619,36 @@ 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 (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.
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.
<details>
<summary><i>Sample request:</i></summary>
```json
{
"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"
]
}]
"action":"addNotes",
"version":6,
"params":{
"notes":[
{
"deckName":"College::PluginDev",
"modelName":"non_existent_model",
"fields":{
"Front":"front",
"Back":"bak"
}
]
}
},
{
"deckName":"College::PluginDev",
"modelName":"Basic",
"fields":{
"Front":"front",
"Back":"bak"
}
}
]
}
}
```
</details>
@ -3650,8 +3658,8 @@ Search parameters are passed to Anki, check the docs for more information: https
```json
{
"result": [1496198395707, null],
"error": null
"result":null,
"error":"['model was not found: non_existent_model']"
}
```
</details>
@ -4166,8 +4174,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 and the cards belonging to
the note.
* 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.
<details>
<summary><i>Sample request:</i></summary>
@ -4191,12 +4199,49 @@ 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

View File

@ -15,10 +15,11 @@
import aqt
required_anki_version = (24, 6, 3)
anki_version = tuple(int(segment) for segment in aqt.appVersion.split("."))
if anki_version < (2, 1, 45):
raise Exception("Minimum Anki version supported: 2.1.45")
if anki_version < required_anki_version:
raise Exception(f"Minimum Anki version supported: {required_anki_version[0]}.{required_anki_version[1]}.{required_anki_version[2]}")
import base64
import glob
@ -310,7 +311,7 @@ class AnkiConnect:
val = note.fields[0]
if not val.strip():
return 1
csum = anki.utils.fieldChecksum(val)
csum = anki.utils.field_checksum(val)
# Create dictionary of deck ids
dids = None
@ -358,13 +359,13 @@ class AnkiConnect:
def getCard(self, card_id: int) -> Card:
try:
return self.collection().getCard(card_id)
return self.collection().get_card(card_id)
except NotFoundError:
self.raiseNotFoundError('Card was not found: {}'.format(card_id))
def getNote(self, note_id: int) -> Note:
try:
return self.collection().getNote(note_id)
return self.collection().get_note(note_id)
except NotFoundError:
self.raiseNotFoundError('Note was not found: {}'.format(note_id))
@ -456,7 +457,10 @@ 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):
@ -488,7 +492,15 @@ class AnkiConnect:
@util.api()
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()
@ -556,7 +568,7 @@ class AnkiConnect:
self.startEditing()
did = self.collection().decks.id(deck)
mod = anki.utils.intTime()
mod = anki.utils.int_time()
usn = self.collection().usn()
# normal cards
@ -601,7 +613,7 @@ class AnkiConnect:
collection = self.collection()
config['id'] = str(config['id'])
config['mod'] = anki.utils.intTime()
config['mod'] = anki.utils.int_time()
config['usn'] = collection.usn()
if int(config['id']) not in [c['id'] for c in collection.decks.all_config()]:
return False
@ -730,7 +742,6 @@ 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
@ -809,18 +820,9 @@ class AnkiConnect:
if name in ankiNote:
ankiNote[name] = value
audioObjectOrList = note.get('audio')
self.addMedia(ankiNote, audioObjectOrList, util.MediaType.Audio)
self.addMediaFromNote(ankiNote, note)
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()
self.collection().update_note(ankiNote, skip_undo_entry=True);
@util.api()
@ -884,11 +886,8 @@ class AnkiConnect:
# Update the tags
anki_note.tags = new_tags
# Flush changes to ensure they are saved
anki_note.flush()
# Save changes to the collection
collection.autosave()
# Update note to ensure changes are saved
collection.update_note(anki_note, skip_undo_entry=True);
@util.api()
def updateNoteTags(self, note, tags):
@ -942,7 +941,7 @@ class AnkiConnect:
if note.has_tag(tag_to_replace):
note.remove_tag(tag_to_replace)
note.add_tag(replace_with_tag)
note.flush()
self.collection().update_note(note, skip_undo_entry=True);
self.window().requireReset()
self.window().progress.finish()
@ -959,7 +958,7 @@ class AnkiConnect:
if note.has_tag(tag_to_replace):
note.remove_tag(tag_to_replace)
note.add_tag(replace_with_tag)
note.flush()
self.collection().update_note(note, skip_undo_entry=True);
self.window().requireReset()
self.window().progress.finish()
@ -978,7 +977,7 @@ class AnkiConnect:
couldSetEaseFactors.append(True)
ankiCard.factor = easeFactors[i]
ankiCard.flush()
self.collection().update_card(ankiCard, skip_undo_entry=True)
return couldSetEaseFactors
@ -1008,7 +1007,7 @@ class AnkiConnect:
ankiCard = self.getCard(card)
for i, key in enumerate(keys):
setattr(ankiCard, key, newValues[i])
ankiCard.flush()
self.collection().update_card(ankiCard, skip_undo_entry=True)
result.append(True)
except Exception as e:
result.append([False, str(e)])
@ -1688,9 +1687,11 @@ 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:
@ -1702,6 +1703,23 @@ 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):
@ -2010,11 +2028,19 @@ class AnkiConnect:
@util.api()
def addNotes(self, notes):
results = []
errs = []
for note in notes:
try:
results.append(self.addNote(note))
except:
results.append(None)
except Exception as e:
# I specifically chose to continue, so we gather all the errors of all notes (ie not break)
errs.append(str(e))
if errs:
# Roll back the changes so on error nothing happens
self.deleteNotes(results)
raise Exception(str(errs))
return results