anki_connect 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +97 -9
- data/examples/add_elevenlabs_tts_audio_to_cloze_cards.rb +75 -0
- data/examples/basic_usage.rb +48 -0
- data/lib/anki_connect/cards.rb +56 -32
- data/lib/anki_connect/client.rb +144 -17
- data/lib/anki_connect/decks.rb +15 -5
- data/lib/anki_connect/graphical.rb +36 -13
- data/lib/anki_connect/media.rb +10 -7
- data/lib/anki_connect/miscellaneous.rb +10 -8
- data/lib/anki_connect/note_types.rb +293 -0
- data/lib/anki_connect/notes.rb +188 -40
- data/lib/anki_connect/params.rb +22 -0
- data/lib/anki_connect/statistics.rb +8 -8
- data/lib/anki_connect/version.rb +1 -1
- data/lib/anki_connect.rb +2 -1
- metadata +12 -4
- data/lib/anki_connect/models.rb +0 -238
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 53abf262d0d5f7f09bb3cd8f6230df92cacec2caaefc73aeef6b7ea2abe2e1e4
|
|
4
|
+
data.tar.gz: bffdfd095a002dab2cab151e3236693504f8c4aa82255a14767e9ee5d495c9d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2743f295c008f02987a00b71398860a45002229ba3a20f199405624aa4551e99d505f76b473d7d5b6815a37940bd9efe64035b52db98d728703fa69dc73c6a6c
|
|
7
|
+
data.tar.gz: d93838989e20753429f684c8141087c9715d922eff948c837794d0e9405836d1ade7d63cd61dde9555c8d3a436661e70334862da8f8f471e346a586235891b9f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 - 2026-07-23
|
|
4
|
+
|
|
5
|
+
- **Breaking:** Redesign the public API around idiomatic Ruby naming and current Anki “note type” terminology, without 0.1 compatibility aliases.
|
|
6
|
+
- Normalize nested wrapper inputs from snake_case to AnkiConnect wire keys.
|
|
7
|
+
- Add wrappers for all nondeprecated actions in AnkiConnect commit `de6e6e1`.
|
|
8
|
+
- Fix the `findAndReplaceInModels` request shape and media-only note updates.
|
|
9
|
+
- Add local argument validation for note selectors, media sources, tags, and parallel card values.
|
|
10
|
+
- Add HTTP and HTTPS endpoint support, configurable timeouts, protocol validation, and typed errors.
|
|
11
|
+
- Require HTTPS when sending API keys to non-loopback endpoints and stop exposing configured keys through a reader.
|
|
12
|
+
- Separate safe unit tests from explicit live integration tests and test Ruby 3.4 and 4.0 in CI.
|
|
13
|
+
|
|
14
|
+
### Renamed API
|
|
15
|
+
|
|
16
|
+
- Cards: `get_ease_factors` -> `ease_factors`; `update_card` -> `set_card_values`; `suspended?` -> `card_suspended?` / `card_suspension_statuses`; `due?` -> `card_due?` / `card_due_statuses`; `get_intervals` -> `card_intervals`; `get_note_ids` -> `note_ids_for_cards`; `get_cards_mod_time` -> `card_modification_times`; `get_cards` -> `cards`.
|
|
17
|
+
- Decks: `get_decks_for_cards` -> `decks_for_cards`; `get_deck_config` -> `deck_config`; `get_deck_stats` -> `deck_stats`.
|
|
18
|
+
- Note types: `model_names` -> `note_type_names`; `model_names_and_ids` -> `note_type_names_and_ids`; `get_models_by_id` / `get_models_by_name` -> `note_types_by_id` / `note_types_by_name`; `get_field_names` / `get_field_descriptions` / `get_field_fonts` / `get_fields_on_templates` -> their `note_type_*` forms; `create_model` -> `create_note_type`; `get_templates` / `get_styling` -> `note_type_templates` / `note_type_styling`; `update_model` -> `update_note_type_templates` / `update_note_type_styling`; `find_and_replace_in_model` -> `find_and_replace_in_note_type`; template and field mutation methods now include `note_type` (for example, `rename_template` -> `rename_note_type_template` and `set_field_font` -> `set_note_type_field_font`).
|
|
19
|
+
- Notes: `can_add_notes` -> `note_addable?` / `note_addability` / `note_addability_statuses` / `note_addability_details`; `change_note_model` -> `change_note_type`; `get_note_tags` -> `note_tags`; `all_tags` -> `tags`; `get_notes` -> `notes`; `get_notes_mod_time` -> `note_modification_times`; `remove_empty_notes` -> `remove_unused_note_types`.
|
|
20
|
+
- Media and miscellaneous: `list_media` -> `media_files`; `media_dir_path` -> `media_directory`; `version` -> `api_version`; `api_reflect` -> `api_capabilities`; `multi` -> `batch`; `import_deck` -> `import_package`.
|
|
21
|
+
- Statistics: `get_reviews` -> `reviews`; `get_reviews_for_cards` -> `reviews_for_cards`; `latest_review_time` -> `latest_review_id`.
|
data/README.md
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
# AnkiConnect Ruby
|
|
2
2
|
|
|
3
|
-
[AnkiConnect](https://git.sr.ht/~foosoft/anki-connect)
|
|
3
|
+
[AnkiConnect provides a simple HTTP API](https://git.sr.ht/~foosoft/anki-connect) to communicate with Anki. This Ruby gem is a wrapper around that API.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Installation
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- Anki with Anki-Connect plugin installed
|
|
7
|
+
First, install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) addon in Anki:
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
1. Open Anki
|
|
10
|
+
2. Go to Tools → Add-ons (⇧⌘A on macOS)
|
|
11
|
+
3. Click "Get Add-ons..."
|
|
12
|
+
4. Enter the code `2055492159` and click OK
|
|
13
|
+
5. Restart Anki
|
|
14
|
+
|
|
15
|
+
Anki must be kept running in the background for other applications to use AnkiConnect. You can verify that AnkiConnect is running by visiting `localhost:8765` in your browser. If the server is running, you will see `{ "apiVersion": "AnkiConnect v.6" }`.
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
Then add this line to your application's Gemfile:
|
|
13
18
|
|
|
14
19
|
```ruby
|
|
15
20
|
gem 'anki_connect'
|
|
@@ -35,13 +40,13 @@ decks = client.deck_names
|
|
|
35
40
|
# Add a new note
|
|
36
41
|
note_id = client.add_note(
|
|
37
42
|
deck_name: "Default",
|
|
38
|
-
|
|
43
|
+
note_type_name: "Basic",
|
|
39
44
|
fields: { Front: "What is Ruby?", Back: "A programming language" },
|
|
40
45
|
tags: ["programming"]
|
|
41
46
|
)
|
|
42
47
|
|
|
43
48
|
# Get note details
|
|
44
|
-
notes = client.
|
|
49
|
+
notes = client.notes(query: "deck:Default")
|
|
45
50
|
# => [{ "noteId" => 1234567890,
|
|
46
51
|
# "modelName" => "Basic",
|
|
47
52
|
# "tags" => ["programming"],
|
|
@@ -49,7 +54,72 @@ notes = client.get_notes(query: "deck:Default")
|
|
|
49
54
|
# "Back" => { "value" => "A programming language", "order" => 1 } } }]
|
|
50
55
|
```
|
|
51
56
|
|
|
52
|
-
|
|
57
|
+
Convenience wrapper inputs use snake_case and current Anki terminology such as `note_type_name`. Response hashes, raw `request` and `batch` payloads, deck configuration round-trips, field names, and action names preserve AnkiConnect's wire keys, including `"modelName"`.
|
|
58
|
+
|
|
59
|
+
`batch` is a low-level escape hatch: each element must be a raw AnkiConnect action envelope with `action`, `version`, and wire-format `params`. Prefer the named wrappers, or `request` for a single action.
|
|
60
|
+
|
|
61
|
+
### Compatibility
|
|
62
|
+
|
|
63
|
+
This release targets AnkiConnect API version 6 and was verified against official AnkiConnect commit [`de6e6e1`](https://git.sr.ht/~foosoft/anki-connect/commit/de6e6e1b8aaf4ae195eb1d1ff6db5409b99b2a3e). That commit is newer than release `25.11.9.0`; `gui_add_note_set_data` requires this post-release revision or newer. The tested upstream version requires Anki 23.10 or newer.
|
|
64
|
+
|
|
65
|
+
The low-level `request` method remains available for actions added after this tested upstream revision.
|
|
66
|
+
|
|
67
|
+
### Enumerable Statuses
|
|
68
|
+
|
|
69
|
+
Scalar predicates return one Boolean. Methods that query multiple cards return arrays in input order so they compose with Ruby's `Enumerable` API:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
statuses = client.card_suspension_statuses(card_ids)
|
|
73
|
+
|
|
74
|
+
statuses.all? # every card is suspended
|
|
75
|
+
statuses.any? # at least one card is suspended
|
|
76
|
+
statuses.none? # no cards are suspended
|
|
77
|
+
statuses.count(true) # number of suspended cards
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Suspension statuses contain `nil` when a card does not exist. Use `compact` or an explicit comparison when missing cards need different handling.
|
|
81
|
+
|
|
82
|
+
### Upgrading From 0.1
|
|
83
|
+
|
|
84
|
+
Version 0.2 is a clean API redesign and does not provide compatibility aliases. Important changes include:
|
|
85
|
+
|
|
86
|
+
- Convenience wrapper inputs use snake_case throughout, including nested note options, media, templates, answers, and GUI options.
|
|
87
|
+
- Public “model” terminology is replaced by Anki's current “note type” terminology.
|
|
88
|
+
- Mechanical `get_*` names are replaced by resource names such as `notes`, `cards`, `deck_config`, and `reviews`.
|
|
89
|
+
- Scalar card predicates and positional status arrays are separate methods.
|
|
90
|
+
- `version` is now `api_version`; the gem version remains `AnkiConnect::VERSION`.
|
|
91
|
+
- `multi` is now `batch`, `api_reflect` is now `api_capabilities`, and `import_deck` is now `import_package`.
|
|
92
|
+
- The misleading `remove_empty_notes` name is now `remove_unused_note_types`, matching its actual upstream behavior.
|
|
93
|
+
|
|
94
|
+
### Client Configuration
|
|
95
|
+
|
|
96
|
+
The client connects to `http://127.0.0.1:8765` by default. A complete endpoint URL, API key, and network timeouts can be configured when needed:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
client = AnkiConnect::Client.new(
|
|
100
|
+
endpoint: "https://anki.example.com/connect",
|
|
101
|
+
api_key: ENV.fetch("ANKI_CONNECT_API_KEY"),
|
|
102
|
+
open_timeout: 5,
|
|
103
|
+
read_timeout: 60,
|
|
104
|
+
write_timeout: 60
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The existing `host:` and `port:` options remain available for HTTP endpoints.
|
|
109
|
+
API keys are allowed over plaintext HTTP only for loopback endpoints; authenticated remote endpoints must use HTTPS. The configured API key is intentionally not exposed through a public reader.
|
|
110
|
+
|
|
111
|
+
### Errors
|
|
112
|
+
|
|
113
|
+
All client errors inherit from `AnkiConnect::Error`:
|
|
114
|
+
|
|
115
|
+
- `AnkiConnect::TransportError` indicates that the HTTP request could not be completed.
|
|
116
|
+
- `AnkiConnect::HTTPError` indicates a non-successful HTTP response and exposes `status` and `body`.
|
|
117
|
+
- `AnkiConnect::ProtocolError` indicates invalid JSON or a response missing the required `result` and `error` fields.
|
|
118
|
+
- `AnkiConnect::APIError` contains an error returned by an AnkiConnect action.
|
|
119
|
+
|
|
120
|
+
Errors expose the failed `action`. `TransportError` also exposes the underlying exception as `original_error`.
|
|
121
|
+
|
|
122
|
+
For a complete list of all API methods, see the [RubyDoc documentation](https://rubydoc.info/gems/anki_connect). More examples in the [`examples/`](examples/) directory.
|
|
53
123
|
|
|
54
124
|
## Development
|
|
55
125
|
|
|
@@ -61,6 +131,24 @@ bundle install
|
|
|
61
131
|
bundle exec rake console
|
|
62
132
|
```
|
|
63
133
|
|
|
134
|
+
### Testing
|
|
135
|
+
|
|
136
|
+
Run the unit tests, which do not require Anki:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
bundle exec rake
|
|
140
|
+
# or explicitly
|
|
141
|
+
bundle exec rake test:unit
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Run the integration tests against a running AnkiConnect instance:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
bundle exec rake test:integration
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Integration tests modify the collection in the active Anki profile. They use a uniquely named temporary deck and only remove the deck when its creation succeeds, but a disposable profile is recommended.
|
|
151
|
+
|
|
64
152
|
## Acknowledgments
|
|
65
153
|
|
|
66
154
|
- [Anki-Connect](https://git.sr.ht/~foosoft/anki-connect) by FooSoft for the excellent Anki plugin
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example: Add ElevenLabs TTS audio to Anki cloze cards
|
|
5
|
+
#
|
|
6
|
+
# I use Anki to learn Spanish with cloze deletion cards for phrases.
|
|
7
|
+
# To train my ear for pronunciation, I want audio for each phrase.
|
|
8
|
+
# This script uses ElevenLabs TTS API to generate speech and attach it to cards.
|
|
9
|
+
#
|
|
10
|
+
# Configuration via environment variables:
|
|
11
|
+
# ELEVENLABS_API_KEY - Your ElevenLabs API key
|
|
12
|
+
# ELEVENLABS_VOICE_ID - Voice ID to use
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# ELEVENLABS_API_KEY=your_key ELEVENLABS_VOICE_ID=voice_id ruby examples/add_elevenlabs_tts_audio_to_cloze_cards.rb
|
|
16
|
+
|
|
17
|
+
require 'net/http'
|
|
18
|
+
require 'json'
|
|
19
|
+
require 'uri'
|
|
20
|
+
require 'anki_connect'
|
|
21
|
+
|
|
22
|
+
ELEVENLABS_API_KEY = ENV.fetch('ELEVENLABS_API_KEY')
|
|
23
|
+
ELEVENLABS_VOICE_ID = ENV.fetch('ELEVENLABS_VOICE_ID')
|
|
24
|
+
|
|
25
|
+
if ELEVENLABS_API_KEY.strip.empty? || ELEVENLABS_VOICE_ID.strip.empty?
|
|
26
|
+
raise 'ELEVENLABS_API_KEY and ELEVENLABS_VOICE_ID are required'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
SPEECH_FOLDER = 'tmp'
|
|
30
|
+
|
|
31
|
+
client = AnkiConnect::Client.new
|
|
32
|
+
client.sync
|
|
33
|
+
|
|
34
|
+
# Customize this query for your deck and note type
|
|
35
|
+
notes = client.notes(query: 'deck:Spanish note:Cloze -tag:fix')
|
|
36
|
+
puts "Found #{notes.length} cloze note(s)"
|
|
37
|
+
|
|
38
|
+
Dir.mkdir(SPEECH_FOLDER) unless Dir.exist?(SPEECH_FOLDER)
|
|
39
|
+
|
|
40
|
+
notes.each_with_index do |note, index|
|
|
41
|
+
print "\rProcessing #{index + 1}/#{notes.length}..."
|
|
42
|
+
|
|
43
|
+
text_field = note.dig('fields', 'Text', 'value')
|
|
44
|
+
next if text_field.nil? || text_field.strip.empty?
|
|
45
|
+
|
|
46
|
+
back_extra = note.dig('fields', 'Back Extra', 'value') || ''
|
|
47
|
+
already_has_audio = back_extra.include?('[sound:')
|
|
48
|
+
next if already_has_audio
|
|
49
|
+
|
|
50
|
+
# Extract full phrase from cloze markup: {{c1::answer::hint}} -> answer
|
|
51
|
+
answer_text = text_field.gsub(/\{\{c\d+::([^:}]+)(?:::[^}]+)?\}\}/, '\1').gsub(/<[^>]+>/, '').gsub(/\s+/, ' ').strip
|
|
52
|
+
|
|
53
|
+
safe_text = answer_text.gsub(/[^a-zA-Z0-9]+/, '_').gsub(/^_+|_+$/, '')[0, 50]
|
|
54
|
+
filename = "elevenlabs_#{note['noteId']}_#{safe_text}.mp3"
|
|
55
|
+
filepath = File.expand_path(File.join(SPEECH_FOLDER, filename))
|
|
56
|
+
|
|
57
|
+
unless File.exist?(filepath)
|
|
58
|
+
uri = URI("https://api.elevenlabs.io/v1/text-to-speech/#{ELEVENLABS_VOICE_ID}")
|
|
59
|
+
request = Net::HTTP::Post.new(uri)
|
|
60
|
+
request['Accept'] = 'audio/mpeg'
|
|
61
|
+
request['xi-api-key'] = ELEVENLABS_API_KEY
|
|
62
|
+
request['Content-Type'] = 'application/json'
|
|
63
|
+
request.body = { text: answer_text, model_id: 'eleven_multilingual_v2', language_code: 'es' }.to_json
|
|
64
|
+
|
|
65
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
|
|
66
|
+
raise "ElevenLabs API error: #{response.code} - #{response.body}" unless response.code == '200'
|
|
67
|
+
|
|
68
|
+
File.binwrite(filepath, response.body)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
client.store_media(filename, path: filepath)
|
|
72
|
+
client.update_note(note['noteId'], fields: { 'Back Extra' => "#{back_extra}[sound:#{filename}]" })
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
puts "\nDone!"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'anki_connect'
|
|
5
|
+
|
|
6
|
+
# Create a client
|
|
7
|
+
client = AnkiConnect::Client.new
|
|
8
|
+
|
|
9
|
+
# Get all decks
|
|
10
|
+
puts 'Available decks:'
|
|
11
|
+
client.deck_names.each { |deck| puts " - #{deck}" }
|
|
12
|
+
|
|
13
|
+
# Get all note types
|
|
14
|
+
puts "\nAvailable note types:"
|
|
15
|
+
client.note_type_names.each { |note_type| puts " - #{note_type}" }
|
|
16
|
+
|
|
17
|
+
# Add a new note
|
|
18
|
+
note_id = client.add_note(
|
|
19
|
+
deck_name: 'Default',
|
|
20
|
+
note_type_name: 'Basic',
|
|
21
|
+
fields: { Front: 'What is the capital of France?', Back: 'Paris' },
|
|
22
|
+
tags: %w[geography europe]
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
puts "\nCreated note with ID: #{note_id}"
|
|
26
|
+
|
|
27
|
+
# Find the note we just created
|
|
28
|
+
note_ids = client.search_notes('tag:geography')
|
|
29
|
+
puts "\nFound #{note_ids.length} notes with tag 'geography'"
|
|
30
|
+
|
|
31
|
+
# Get detailed info about the notes
|
|
32
|
+
notes = client.notes(note_ids: note_ids)
|
|
33
|
+
notes.each do |note|
|
|
34
|
+
puts "\nNote #{note['noteId']}:"
|
|
35
|
+
puts " Note type: #{note['modelName']}"
|
|
36
|
+
puts " Tags: #{note['tags'].join(', ')}"
|
|
37
|
+
note['fields'].each do |field_name, field_data|
|
|
38
|
+
puts " #{field_name}: #{field_data['value']}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Get statistics
|
|
43
|
+
reviewed_today = client.cards_reviewed_today
|
|
44
|
+
puts "\nCards reviewed today: #{reviewed_today}"
|
|
45
|
+
|
|
46
|
+
# Clean up - delete the note we created
|
|
47
|
+
client.delete_notes([note_id])
|
|
48
|
+
puts "\nDeleted note #{note_id}"
|
data/lib/anki_connect/cards.rb
CHANGED
|
@@ -4,11 +4,17 @@ module AnkiConnect
|
|
|
4
4
|
class Client
|
|
5
5
|
# Methods to query, modify, suspend, and manage individual flashcards.
|
|
6
6
|
module Cards
|
|
7
|
+
ANSWER_KEYS = {
|
|
8
|
+
'card_id' => :cardId,
|
|
9
|
+
'ease' => :ease
|
|
10
|
+
}.freeze
|
|
11
|
+
private_constant :ANSWER_KEYS
|
|
12
|
+
|
|
7
13
|
# Gets ease factors for cards.
|
|
8
14
|
#
|
|
9
15
|
# @param card_ids [Array<Integer>] Array of card IDs
|
|
10
|
-
# @return [Array<Integer>]
|
|
11
|
-
def
|
|
16
|
+
# @return [Array<Integer, nil>] Ease factors; nil for missing cards
|
|
17
|
+
def ease_factors(card_ids)
|
|
12
18
|
request(:getEaseFactors, cards: card_ids)
|
|
13
19
|
end
|
|
14
20
|
|
|
@@ -18,16 +24,19 @@ module AnkiConnect
|
|
|
18
24
|
# @param factors [Array<Integer>] Array of ease factor values
|
|
19
25
|
# @return [Array<Boolean>] Array indicating success for each card
|
|
20
26
|
def set_ease_factors(card_ids, factors)
|
|
27
|
+
raise ArgumentError, 'card_ids and factors must have the same length' unless card_ids.length == factors.length
|
|
28
|
+
|
|
21
29
|
request(:setEaseFactors, cards: card_ids, easeFactors: factors)
|
|
22
30
|
end
|
|
23
31
|
|
|
24
|
-
# Sets
|
|
32
|
+
# Sets raw database values for a single card.
|
|
33
|
+
# This low-level operation can corrupt scheduling data when used incorrectly.
|
|
25
34
|
#
|
|
26
35
|
# @param card_id [Integer] Card ID
|
|
27
36
|
# @param fields [Hash] Database field names to new values
|
|
28
37
|
# @param warning_check [Boolean] Must be true for certain risky keys
|
|
29
|
-
# @return [Array
|
|
30
|
-
def
|
|
38
|
+
# @return [Array, Boolean] Upstream result for the single card update
|
|
39
|
+
def set_card_values(card_id, fields, warning_check: false)
|
|
31
40
|
request(:setSpecificValueOfCard, card: card_id, keys: fields.keys, newValues: fields.values,
|
|
32
41
|
warning_check: warning_check)
|
|
33
42
|
end
|
|
@@ -43,33 +52,41 @@ module AnkiConnect
|
|
|
43
52
|
# Unsuspends cards.
|
|
44
53
|
#
|
|
45
54
|
# @param card_ids [Array<Integer>] Array of card IDs
|
|
46
|
-
# @return [
|
|
55
|
+
# @return [nil] Current AnkiConnect does not return the underlying result
|
|
47
56
|
def unsuspend_cards(card_ids)
|
|
48
57
|
request(:unsuspend, cards: card_ids)
|
|
49
58
|
end
|
|
50
59
|
|
|
51
|
-
# Checks
|
|
60
|
+
# Checks whether one card is suspended.
|
|
52
61
|
#
|
|
53
|
-
# @param
|
|
54
|
-
# @return [Boolean
|
|
55
|
-
def
|
|
56
|
-
|
|
57
|
-
request(:areSuspended, cards: card_ids)
|
|
58
|
-
else
|
|
59
|
-
request(:suspended, card: card_ids)
|
|
60
|
-
end
|
|
62
|
+
# @param card_id [Integer] Card ID
|
|
63
|
+
# @return [Boolean] Suspension status
|
|
64
|
+
def card_suspended?(card_id)
|
|
65
|
+
request(:suspended, card: card_id)
|
|
61
66
|
end
|
|
62
67
|
|
|
63
|
-
#
|
|
68
|
+
# Gets suspension status for each card, preserving input order.
|
|
64
69
|
#
|
|
65
|
-
# @param card_ids [
|
|
66
|
-
# @return [Boolean,
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
# @param card_ids [Array<Integer>] Card IDs
|
|
71
|
+
# @return [Array<Boolean, nil>] Statuses; nil for missing cards
|
|
72
|
+
def card_suspension_statuses(card_ids)
|
|
73
|
+
request(:areSuspended, cards: card_ids)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Checks whether one card is due for review.
|
|
77
|
+
#
|
|
78
|
+
# @param card_id [Integer] Card ID
|
|
79
|
+
# @return [Boolean] Due status
|
|
80
|
+
def card_due?(card_id)
|
|
81
|
+
request(:areDue, cards: [card_id]).first
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Gets due status for each card, preserving input order.
|
|
85
|
+
#
|
|
86
|
+
# @param card_ids [Array<Integer>] Card IDs
|
|
87
|
+
# @return [Array<Boolean>] Due statuses
|
|
88
|
+
def card_due_statuses(card_ids)
|
|
89
|
+
request(:areDue, cards: card_ids)
|
|
73
90
|
end
|
|
74
91
|
|
|
75
92
|
# Gets intervals for cards.
|
|
@@ -77,7 +94,7 @@ module AnkiConnect
|
|
|
77
94
|
# @param card_ids [Array<Integer>] Array of card IDs
|
|
78
95
|
# @param complete [Boolean] If true, returns all intervals
|
|
79
96
|
# @return [Array<Integer>, Array<Array<Integer>>] Intervals
|
|
80
|
-
def
|
|
97
|
+
def card_intervals(card_ids, complete: false)
|
|
81
98
|
request(:getIntervals, cards: card_ids, complete: complete)
|
|
82
99
|
end
|
|
83
100
|
|
|
@@ -89,11 +106,11 @@ module AnkiConnect
|
|
|
89
106
|
request(:findCards, query: query)
|
|
90
107
|
end
|
|
91
108
|
|
|
92
|
-
#
|
|
109
|
+
# Gets unique parent note IDs for cards.
|
|
93
110
|
#
|
|
94
111
|
# @param card_ids [Array<Integer>] Array of card IDs
|
|
95
|
-
# @return [Array<Integer>]
|
|
96
|
-
def
|
|
112
|
+
# @return [Array<Integer>] Unordered, unique note IDs
|
|
113
|
+
def note_ids_for_cards(card_ids)
|
|
97
114
|
request(:cardsToNotes, cards: card_ids)
|
|
98
115
|
end
|
|
99
116
|
|
|
@@ -101,7 +118,7 @@ module AnkiConnect
|
|
|
101
118
|
#
|
|
102
119
|
# @param card_ids [Array<Integer>] Array of card IDs
|
|
103
120
|
# @return [Array<Hash>] Array of objects with cardId and mod
|
|
104
|
-
def
|
|
121
|
+
def card_modification_times(card_ids)
|
|
105
122
|
request(:cardsModTime, cards: card_ids)
|
|
106
123
|
end
|
|
107
124
|
|
|
@@ -109,7 +126,7 @@ module AnkiConnect
|
|
|
109
126
|
#
|
|
110
127
|
# @param card_ids [Array<Integer>] Array of card IDs
|
|
111
128
|
# @return [Array<Hash>] Array of card objects
|
|
112
|
-
def
|
|
129
|
+
def cards(card_ids)
|
|
113
130
|
request(:cardsInfo, cards: card_ids)
|
|
114
131
|
end
|
|
115
132
|
|
|
@@ -131,10 +148,17 @@ module AnkiConnect
|
|
|
131
148
|
|
|
132
149
|
# Answers cards programmatically.
|
|
133
150
|
#
|
|
134
|
-
# @param answers [Array<Hash>] Array of {
|
|
151
|
+
# @param answers [Array<Hash>] Array of { card_id:, ease: } (1=Again, 2=Hard, 3=Good, 4=Easy)
|
|
135
152
|
# @return [Array<Boolean>] Array indicating if each card exists
|
|
136
153
|
def answer_cards(answers)
|
|
137
|
-
|
|
154
|
+
normalized = answers.map do |answer|
|
|
155
|
+
answer = normalize_keys(answer, ANSWER_KEYS, name: 'answer')
|
|
156
|
+
missing_keys = ANSWER_KEYS.values.reject { |key| answer.key?(key) }
|
|
157
|
+
raise ArgumentError, "missing answer keys: #{missing_keys.join(', ')}" unless missing_keys.empty?
|
|
158
|
+
|
|
159
|
+
answer
|
|
160
|
+
end
|
|
161
|
+
request(:answerCards, answers: normalized)
|
|
138
162
|
end
|
|
139
163
|
|
|
140
164
|
# Sets due date for cards.
|
data/lib/anki_connect/client.rb
CHANGED
|
@@ -3,14 +3,26 @@
|
|
|
3
3
|
require 'net/http'
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'uri'
|
|
6
|
+
require 'ipaddr'
|
|
7
|
+
require_relative 'version'
|
|
8
|
+
require_relative 'params'
|
|
9
|
+
require_relative 'cards'
|
|
10
|
+
require_relative 'decks'
|
|
11
|
+
require_relative 'note_types'
|
|
12
|
+
require_relative 'notes'
|
|
13
|
+
require_relative 'media'
|
|
14
|
+
require_relative 'graphical'
|
|
15
|
+
require_relative 'statistics'
|
|
16
|
+
require_relative 'miscellaneous'
|
|
6
17
|
|
|
7
18
|
module AnkiConnect
|
|
8
19
|
# Main client class that includes all API modules and provides
|
|
9
20
|
# the core request mechanism.
|
|
10
21
|
class Client
|
|
22
|
+
include AnkiConnect::Client::Params
|
|
11
23
|
include AnkiConnect::Client::Cards
|
|
12
24
|
include AnkiConnect::Client::Decks
|
|
13
|
-
include AnkiConnect::Client::
|
|
25
|
+
include AnkiConnect::Client::NoteTypes
|
|
14
26
|
include AnkiConnect::Client::Notes
|
|
15
27
|
include AnkiConnect::Client::Media
|
|
16
28
|
include AnkiConnect::Client::Graphical
|
|
@@ -21,19 +33,37 @@ module AnkiConnect
|
|
|
21
33
|
attr_reader :host
|
|
22
34
|
# @return [Integer] AnkiConnect server port
|
|
23
35
|
attr_reader :port
|
|
24
|
-
# @return [
|
|
25
|
-
attr_reader :
|
|
36
|
+
# @return [URI::HTTP, URI::HTTPS] AnkiConnect endpoint
|
|
37
|
+
attr_reader :endpoint
|
|
38
|
+
# @return [Numeric] Connection timeout in seconds
|
|
39
|
+
attr_reader :open_timeout
|
|
40
|
+
# @return [Numeric] Response timeout in seconds
|
|
41
|
+
attr_reader :read_timeout
|
|
42
|
+
# @return [Numeric] Request write timeout in seconds
|
|
43
|
+
attr_reader :write_timeout
|
|
26
44
|
|
|
27
45
|
# Creates a new AnkiConnect client.
|
|
28
46
|
#
|
|
29
47
|
# @param host [String] AnkiConnect server host (default: "127.0.0.1")
|
|
30
48
|
# @param port [Integer] AnkiConnect server port (default: 8765)
|
|
49
|
+
# @param endpoint [String, URI, nil] Complete HTTP or HTTPS endpoint; overrides host and port
|
|
31
50
|
# @param api_key [String, nil] Optional API key for authentication
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
51
|
+
# @param open_timeout [Numeric] Connection timeout in seconds
|
|
52
|
+
# @param read_timeout [Numeric] Response timeout in seconds
|
|
53
|
+
# @param write_timeout [Numeric] Request write timeout in seconds
|
|
54
|
+
def initialize(
|
|
55
|
+
host: '127.0.0.1', port: 8765, endpoint: nil, api_key: nil,
|
|
56
|
+
open_timeout: 5, read_timeout: 60, write_timeout: 60
|
|
57
|
+
)
|
|
58
|
+
@endpoint = parse_endpoint(endpoint ? endpoint.to_s : "http://#{host}:#{port}")
|
|
59
|
+
validate_endpoint!
|
|
60
|
+
validate_api_key_transport!(api_key)
|
|
61
|
+
@host = @endpoint.hostname
|
|
62
|
+
@port = @endpoint.port
|
|
35
63
|
@api_key = api_key
|
|
36
|
-
@
|
|
64
|
+
@open_timeout = open_timeout
|
|
65
|
+
@read_timeout = read_timeout
|
|
66
|
+
@write_timeout = write_timeout
|
|
37
67
|
end
|
|
38
68
|
|
|
39
69
|
# Makes a request to the AnkiConnect API.
|
|
@@ -51,24 +81,121 @@ module AnkiConnect
|
|
|
51
81
|
}
|
|
52
82
|
body[:key] = @api_key if @api_key
|
|
53
83
|
|
|
54
|
-
response =
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
84
|
+
response = perform_request(body.to_json, action)
|
|
85
|
+
unless response.code.to_i.between?(200, 299)
|
|
86
|
+
raise HTTPError.new(
|
|
87
|
+
"AnkiConnect returned HTTP #{response.code}",
|
|
88
|
+
action: action,
|
|
89
|
+
status: response.code.to_i,
|
|
90
|
+
body: response.body
|
|
91
|
+
)
|
|
92
|
+
end
|
|
59
93
|
|
|
60
|
-
result =
|
|
94
|
+
result = parse_response(response.body, action)
|
|
61
95
|
|
|
62
|
-
raise
|
|
96
|
+
raise APIError.new(result['error'].to_s, action: action) unless result['error'].nil?
|
|
63
97
|
|
|
64
98
|
result['result']
|
|
65
99
|
end
|
|
66
100
|
|
|
67
101
|
private
|
|
68
102
|
|
|
69
|
-
|
|
103
|
+
def perform_request(body, action)
|
|
104
|
+
http = Net::HTTP.new(endpoint.hostname, endpoint.port)
|
|
105
|
+
http.use_ssl = endpoint.scheme == 'https'
|
|
106
|
+
http.open_timeout = open_timeout
|
|
107
|
+
http.read_timeout = read_timeout
|
|
108
|
+
http.write_timeout = write_timeout
|
|
109
|
+
|
|
110
|
+
request = Net::HTTP::Post.new(endpoint.request_uri)
|
|
111
|
+
request['Content-Type'] = 'application/json'
|
|
112
|
+
request.body = body
|
|
113
|
+
|
|
114
|
+
http.start { |connection| connection.request(request) }
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
raise TransportError.new(e.message, action: action, original_error: e), cause: e
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def parse_response(body, action)
|
|
120
|
+
result = JSON.parse(body)
|
|
121
|
+
unless result.is_a?(Hash) && result.key?('result') && result.key?('error')
|
|
122
|
+
raise ProtocolError.new('AnkiConnect response must contain result and error', action: action, body: body)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
result
|
|
126
|
+
rescue JSON::ParserError => e
|
|
127
|
+
raise ProtocolError.new('AnkiConnect returned invalid JSON', action: action, body: body), cause: e
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def validate_endpoint!
|
|
131
|
+
return if endpoint.is_a?(URI::HTTP) && endpoint.host
|
|
132
|
+
|
|
133
|
+
raise ArgumentError, 'endpoint must be an HTTP or HTTPS URL with a host'
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def validate_api_key_transport!(api_key)
|
|
137
|
+
return if api_key.nil? || endpoint.scheme == 'https' || loopback_endpoint?
|
|
138
|
+
|
|
139
|
+
raise ArgumentError, 'api_key requires HTTPS for non-loopback endpoints'
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def loopback_endpoint?
|
|
143
|
+
host = endpoint.hostname.downcase
|
|
144
|
+
return true if host == 'localhost'
|
|
145
|
+
|
|
146
|
+
IPAddr.new(host).loopback?
|
|
147
|
+
rescue IPAddr::InvalidAddressError
|
|
148
|
+
false
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def parse_endpoint(value)
|
|
152
|
+
URI(value)
|
|
153
|
+
rescue URI::InvalidURIError => e
|
|
154
|
+
raise ArgumentError, 'endpoint must be an HTTP or HTTPS URL with a host', cause: e
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Base error for AnkiConnect request failures.
|
|
159
|
+
class Error < StandardError
|
|
160
|
+
attr_reader :action
|
|
161
|
+
|
|
162
|
+
def initialize(message = nil, action: nil)
|
|
163
|
+
@action = action
|
|
164
|
+
super(message)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Error raised when the HTTP request cannot be completed.
|
|
169
|
+
class TransportError < Error
|
|
170
|
+
attr_reader :original_error
|
|
171
|
+
|
|
172
|
+
def initialize(message = nil, original_error:, **context)
|
|
173
|
+
@original_error = original_error
|
|
174
|
+
super(message, **context)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Error raised for a non-successful HTTP response.
|
|
179
|
+
class HTTPError < Error
|
|
180
|
+
attr_reader :status, :body
|
|
181
|
+
|
|
182
|
+
def initialize(message = nil, status:, body:, **context)
|
|
183
|
+
@status = status
|
|
184
|
+
@body = body
|
|
185
|
+
super(message, **context)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Error raised when the response does not follow the AnkiConnect protocol.
|
|
190
|
+
class ProtocolError < Error
|
|
191
|
+
attr_reader :body
|
|
192
|
+
|
|
193
|
+
def initialize(message = nil, body:, **context)
|
|
194
|
+
@body = body
|
|
195
|
+
super(message, **context)
|
|
196
|
+
end
|
|
70
197
|
end
|
|
71
198
|
|
|
72
|
-
# Error
|
|
73
|
-
class
|
|
199
|
+
# Error returned by an AnkiConnect action.
|
|
200
|
+
class APIError < Error; end
|
|
74
201
|
end
|