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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12cd7e1f5f63083dfb30d873a1ba70985b90b2107dcb50de09f151b8414532aa
4
- data.tar.gz: 03145b84fe4f8ef9bb7412d2bc65f79dafffcdc9a8758dc0ff84e3b1263f41f1
3
+ metadata.gz: 53abf262d0d5f7f09bb3cd8f6230df92cacec2caaefc73aeef6b7ea2abe2e1e4
4
+ data.tar.gz: bffdfd095a002dab2cab151e3236693504f8c4aa82255a14767e9ee5d495c9d0
5
5
  SHA512:
6
- metadata.gz: 46dffccf988e0497c76c7129d40be5b2becd687615730d3b53ff515eceb2a0ddc93d991929c7de8b56e2fae8d57e7419c21d92059df636a125d5d49ab03ab2d9
7
- data.tar.gz: 261dd1bcd832014c623ee8c87ade177c0084e0d578e6808f138e6170a7464110ee98c5e07e20244a6e2b85bef72ddeb1f7e97f71c25a2a481e3eb4830fcd89f2
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) provides a simple HTTP API to communicate with Anki. This Ruby gem is a wrapper around that API.
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
- ## Requirements
5
+ ## Installation
6
6
 
7
- - Ruby 3.4+
8
- - Anki with Anki-Connect plugin installed
7
+ First, install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) addon in Anki:
9
8
 
10
- ## Installation
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
- Add this line to your application's Gemfile:
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
- model_name: "Basic",
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.get_notes(query: "deck:Default")
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
- For more examples, see the [`examples/`](examples/) directory.
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}"
@@ -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>] Array of ease factor values
11
- def get_ease_factors(card_ids)
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 specific database values for a single card.
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<Boolean>] Array indicating success for each field
30
- def update_card(card_id, fields, warning_check: false)
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 [Boolean] true if at least one card was previously suspended
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 suspension status for cards.
60
+ # Checks whether one card is suspended.
52
61
  #
53
- # @param card_ids [Integer, Array<Integer>] Single card ID or array
54
- # @return [Boolean, Array<Boolean, nil>] Boolean for single, array for multiple
55
- def suspended?(card_ids)
56
- if card_ids.is_a?(Array)
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
- # Checks if cards are due for review.
68
+ # Gets suspension status for each card, preserving input order.
64
69
  #
65
- # @param card_ids [Integer, Array<Integer>] Single card ID or array
66
- # @return [Boolean, Array<Boolean>] Boolean for single, array for multiple
67
- def due?(card_ids)
68
- if card_ids.is_a?(Array)
69
- request(:areDue, cards: card_ids)
70
- else
71
- request(:areDue, cards: [card_ids]).first
72
- end
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 get_intervals(card_ids, complete: false)
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
- # Converts card IDs to their parent note IDs.
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>] Array of note IDs
96
- def get_note_ids(card_ids)
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 get_cards_mod_time(card_ids)
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 get_cards(card_ids)
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 { cardId:, ease: } (1=Again, 2=Hard, 3=Good, 4=Easy)
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
- request(:answerCards, answers: answers)
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.
@@ -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::Models
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 [String, nil] API key for authentication (if configured)
25
- attr_reader :api_key
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
- def initialize(host: '127.0.0.1', port: 8765, api_key: nil)
33
- @host = host
34
- @port = port
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
- @uri = URI("http://#{host}:#{port}")
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 = Net::HTTP.post(
55
- @uri,
56
- body.to_json,
57
- 'Content-Type' => 'application/json'
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 = JSON.parse(response.body)
94
+ result = parse_response(response.body, action)
61
95
 
62
- raise Error, result['error'] if result['error']
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
- attr_reader :uri
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 raised when the AnkiConnect API returns an error response.
73
- class Error < StandardError; end
199
+ # Error returned by an AnkiConnect action.
200
+ class APIError < Error; end
74
201
  end