anki_generator 1.1.0 → 1.4.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +59 -0
  3. data/.rubocop.yml +79 -0
  4. data/.ruby-version +1 -0
  5. data/.tool-versions +1 -0
  6. data/CHANGELOG.md +173 -0
  7. data/Gemfile +21 -0
  8. data/Makefile +20 -0
  9. data/README.md +160 -32
  10. data/Rakefile +174 -0
  11. data/anki_generator.gemspec +42 -0
  12. data/bin/anki_generator +3 -2
  13. data/docs/CI_SETUP.md +120 -0
  14. data/docs/architecture/current-v1.3.0.architecture.json +315 -0
  15. data/docs/architecture/current-v1.3.0.html +14990 -0
  16. data/docs/architecture/current-v1.3.0.visual-check.json +548 -0
  17. data/docs/architecture/phase3-proposed.architecture.json +310 -0
  18. data/docs/architecture/phase3-proposed.html +15001 -0
  19. data/docs/architecture/phase3-proposed.visual-check.json +548 -0
  20. data/docs/phase3-draft.md +86 -0
  21. data/examples/example_class.rb +13 -0
  22. data/examples/manual_cards.yaml +5 -0
  23. data/examples/study_prompt.txt +3 -0
  24. data/input/input.yaml.example +3 -0
  25. data/lib/anki_generator/anki_connect_client.rb +85 -0
  26. data/lib/anki_generator/apkg_schema.rb +257 -0
  27. data/lib/anki_generator/apkg_writer.rb +149 -0
  28. data/lib/anki_generator/card.rb +83 -0
  29. data/lib/anki_generator/cli.rb +183 -0
  30. data/lib/anki_generator/client_factory.rb +20 -0
  31. data/lib/anki_generator/commands/create_ai_template.rb +39 -0
  32. data/lib/anki_generator/commands/generate_deck.rb +43 -0
  33. data/lib/anki_generator/commands/generate_yaml.rb +63 -0
  34. data/lib/anki_generator/commands/import.rb +75 -0
  35. data/lib/anki_generator/commands/prompt_based.rb +74 -0
  36. data/lib/anki_generator/commands/prompt_to_deck.rb +84 -0
  37. data/lib/anki_generator/commands/push.rb +32 -0
  38. data/lib/anki_generator/commands/serve.rb +99 -0
  39. data/lib/anki_generator/commands/test_api.rb +33 -0
  40. data/lib/anki_generator/deck_builder.rb +184 -0
  41. data/lib/anki_generator/errors.rb +22 -0
  42. data/lib/anki_generator/file_processor.rb +154 -0
  43. data/lib/anki_generator/importers/csv.rb +52 -0
  44. data/lib/anki_generator/importers/markdown.rb +72 -0
  45. data/lib/anki_generator/prompt_builder.rb +77 -0
  46. data/lib/anki_generator/server.rb +98 -0
  47. data/lib/anki_generator/ui.rb +28 -0
  48. data/lib/anki_generator/version.rb +5 -0
  49. data/lib/anki_generator.rb +18 -114
  50. data/prompt.txt +5 -0
  51. metadata +100 -43
  52. data/lib/anki_cli.rb +0 -259
  53. data/lib/file_processor.rb +0 -156
  54. data/lib/openrouter_client.rb +0 -158
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+ require_relative 'card'
6
+ require_relative 'errors'
7
+
8
+ module AnkiGenerator
9
+ # Talks to AnkiConnect (https://foosoft.net/projects/anki-connect), the
10
+ # add-on that exposes a local HTTP API inside a running Anki. Pushes cards
11
+ # straight into a deck — no .apkg import step.
12
+ class AnkiConnectClient
13
+ DEFAULT_URL = 'http://localhost:8765'
14
+ MODEL_NAME = 'Basic'
15
+
16
+ attr_reader :base_url
17
+
18
+ def initialize(base_url: DEFAULT_URL)
19
+ @base_url = base_url
20
+ end
21
+
22
+ # Creates the deck if needed and adds the cards. Returns
23
+ # { added:, duplicate: } counts. Raises ApiError when Anki is unreachable
24
+ # or AnkiConnect reports a failure.
25
+ def push_deck(deck_name:, cards:)
26
+ ensure_deck(deck_name)
27
+
28
+ payload = Array(cards).map do |card|
29
+ {
30
+ deckName: deck_name,
31
+ modelName: MODEL_NAME,
32
+ fields: { Front: card.front, Back: card.back },
33
+ tags: card.tags,
34
+ options: { allowDuplicate: false }
35
+ }
36
+ end
37
+
38
+ response = invoke('addNotes', notes: payload)
39
+ { added: response.compact.length, duplicate: response.count(nil) }
40
+ end
41
+
42
+ def deck_names
43
+ invoke('deckNames')
44
+ end
45
+
46
+ def create_deck(name)
47
+ invoke('createDeck', deck: name)
48
+ end
49
+
50
+ # AnkiConnect answers every action with {result, error}.
51
+ def invoke(action, **params)
52
+ response = connection.post do |request|
53
+ request.body = { action:, version: 6, params: }.to_json
54
+ end
55
+
56
+ unless response.success?
57
+ raise ApiError,
58
+ "AnkiConnect error #{response.status}: #{response.body} (is Anki running with AnkiConnect?)"
59
+ end
60
+
61
+ body = response.body
62
+ raise ApiError, "AnkiConnect error: #{body['error']}" if body.is_a?(Hash) && body['error']
63
+
64
+ body.is_a?(Hash) ? body['result'] : body
65
+ rescue Faraday::ConnectionFailed => e
66
+ raise ApiError, "Cannot reach AnkiConnect at #{@base_url}: #{e.message}"
67
+ end
68
+
69
+ private
70
+
71
+ def ensure_deck(deck_name)
72
+ create_deck(deck_name) unless deck_names.include?(deck_name)
73
+ end
74
+
75
+ def connection
76
+ @connection ||= Faraday.new(url: @base_url) do |conn|
77
+ conn.request :json
78
+ conn.response :json
79
+ conn.adapter Faraday.default_adapter
80
+ conn.options.open_timeout = 5
81
+ conn.options.timeout = 60
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module AnkiGenerator
6
+ # Static Anki 2.1 collection definitions: the SQLite schema and the default
7
+ # conf/deck/model JSON blobs that populate the col table. Extracted from
8
+ # ApkgWriter so the writer deals only in rows and the archive.
9
+ module ApkgSchema
10
+ MODEL_ID = 1_609_739_310_911
11
+ MODEL_NAME = 'AnkiGenerator'
12
+ CLOZE_MODEL_ID = 1_609_739_310_912
13
+ CLOZE_MODEL_NAME = 'AnkiGenerator Cloze'
14
+ DECK_ID = 1
15
+ SCHEMA_VERSION = 11
16
+
17
+ CARD_CSS = <<~CSS
18
+ .card {
19
+ font-family: arial;
20
+ font-size: 20px;
21
+ text-align: center;
22
+ color: black;
23
+ background-color: white;
24
+ }
25
+ CSS
26
+
27
+ SQL = <<~SQL
28
+ CREATE TABLE col (
29
+ id integer primary key,
30
+ crt integer not null,
31
+ mod integer not null,
32
+ scm integer not null,
33
+ ver integer not null,
34
+ dty integer not null,
35
+ usn integer not null,
36
+ ls integer not null,
37
+ conf text not null,
38
+ models text not null,
39
+ decks text not null,
40
+ dconf text not null,
41
+ tags text not null
42
+ );
43
+ CREATE TABLE notes (
44
+ id integer primary key,
45
+ guid text not null,
46
+ mid integer not null,
47
+ mod integer not null,
48
+ usn integer not null,
49
+ tags text not null,
50
+ flds text not null,
51
+ sfld integer not null,
52
+ csum integer not null,
53
+ flags integer not null,
54
+ data text not null
55
+ );
56
+ CREATE TABLE cards (
57
+ id integer primary key,
58
+ nid integer not null,
59
+ did integer not null,
60
+ ord integer not null,
61
+ mod integer not null,
62
+ usn integer not null,
63
+ type integer not null,
64
+ queue integer not null,
65
+ due integer not null,
66
+ ivl integer not null,
67
+ factor integer not null,
68
+ reps integer not null,
69
+ lapses integer not null,
70
+ left integer not null,
71
+ odue integer not null,
72
+ odid integer not null,
73
+ flags integer not null,
74
+ data text not null
75
+ );
76
+ CREATE INDEX ix_cards_nid ON cards (nid);
77
+ CREATE INDEX ix_notes_csum ON notes (csum);
78
+ SQL
79
+
80
+ module_function
81
+
82
+ def conf_json
83
+ JSON.generate(
84
+ 'nextPos' => 1,
85
+ 'estTimes' => true,
86
+ 'activeDecks' => [DECK_ID],
87
+ 'sortType' => 'noteFld',
88
+ 'timeLim' => 0,
89
+ 'sortBackwards' => false,
90
+ 'addToCur' => true,
91
+ 'curDeck' => DECK_ID,
92
+ 'newBury' => true,
93
+ 'newSpread' => 0,
94
+ 'dueDisplay' => 0,
95
+ 'collapseTime' => 1200,
96
+ 'curModel' => MODEL_ID.to_s
97
+ )
98
+ end
99
+
100
+ def decks_json(deck_name:, now_seconds:)
101
+ JSON.generate(
102
+ DECK_ID.to_s => {
103
+ 'id' => DECK_ID,
104
+ 'mod' => now_seconds,
105
+ 'name' => deck_name,
106
+ 'usn' => -1,
107
+ 'lnewToday' => [0, 0],
108
+ 'lrnToday' => [0, 0],
109
+ 'revToday' => [0, 0],
110
+ 'timeToday' => [0, 0],
111
+ 'newToday' => [0, 0],
112
+ 'conf' => 1,
113
+ 'collapsed' => false,
114
+ 'dyn' => 0,
115
+ 'extendNew' => 0,
116
+ 'extendRev' => 0,
117
+ 'browserCollapsed' => true,
118
+ 'desc' => ''
119
+ }
120
+ )
121
+ end
122
+
123
+ def dconf_json
124
+ JSON.generate(
125
+ '1' => {
126
+ 'id' => 1,
127
+ 'mod' => 0,
128
+ 'name' => 'Default',
129
+ 'usn' => 0,
130
+ 'maxTaken' => 60,
131
+ 'timer' => 0,
132
+ 'autoplay' => true,
133
+ 'replayq' => true,
134
+ 'dyn' => false,
135
+ 'newMix' => 0,
136
+ 'newPer' => 0,
137
+ 'resched' => true,
138
+ 'new' => {
139
+ 'delays' => [1, 10],
140
+ 'ints' => [1, 4, 7],
141
+ 'initialFactor' => 2500,
142
+ 'separate' => true,
143
+ 'order' => 1,
144
+ 'perDay' => 20,
145
+ 'bury' => false
146
+ },
147
+ 'lapse' => {
148
+ 'delays' => [10],
149
+ 'mult' => 0,
150
+ 'minInt' => 1,
151
+ 'leechFails' => 8,
152
+ 'leechAction' => 0
153
+ },
154
+ 'rev' => {
155
+ 'perDay' => 200,
156
+ 'ease4' => 1.3,
157
+ 'fuzz' => 0.05,
158
+ 'minSpace' => 1,
159
+ 'ivlFct' => 1,
160
+ 'maxIvl' => 36_500
161
+ }
162
+ }
163
+ )
164
+ end
165
+
166
+ def models_json(now_seconds:)
167
+ JSON.generate(
168
+ MODEL_ID.to_s => basic_model(now_seconds),
169
+ CLOZE_MODEL_ID.to_s => cloze_model(now_seconds)
170
+ )
171
+ end
172
+
173
+ def basic_model(now_seconds)
174
+ {
175
+ 'id' => MODEL_ID,
176
+ 'name' => MODEL_NAME,
177
+ 'mod' => now_seconds,
178
+ 'usn' => -1,
179
+ 'sortf' => 0,
180
+ 'type' => 0,
181
+ 'css' => CARD_CSS,
182
+ 'tags' => [],
183
+ 'flds' => [field('Front', 0), field('Back', 1)],
184
+ 'tmpls' => [template],
185
+ 'latexPre' => latex_pre,
186
+ 'latexPost' => '\\end{document}',
187
+ 'req' => [[0, 'any', [0]]]
188
+ }
189
+ end
190
+
191
+ def cloze_model(now_seconds)
192
+ {
193
+ 'id' => CLOZE_MODEL_ID,
194
+ 'name' => CLOZE_MODEL_NAME,
195
+ 'mod' => now_seconds,
196
+ 'usn' => -1,
197
+ 'sortf' => 0,
198
+ 'type' => 1,
199
+ 'css' => CARD_CSS,
200
+ 'tags' => [],
201
+ 'flds' => [field('Text', 0)],
202
+ 'tmpls' => [cloze_template],
203
+ 'latexPre' => latex_pre,
204
+ 'latexPost' => '\\end{document}',
205
+ 'req' => [[0, 'all', [0]]]
206
+ }
207
+ end
208
+
209
+ def field(name, ord)
210
+ {
211
+ 'name' => name,
212
+ 'ord' => ord,
213
+ 'sticky' => false,
214
+ 'rtl' => false,
215
+ 'font' => 'Arial',
216
+ 'size' => 20,
217
+ 'media' => []
218
+ }
219
+ end
220
+
221
+ def template
222
+ {
223
+ 'name' => 'Card 1',
224
+ 'ord' => 0,
225
+ 'qfmt' => '{{Front}}',
226
+ 'afmt' => "{{FrontSide}}\n\n<hr id=answer>\n\n{{Back}}",
227
+ 'did' => nil,
228
+ 'bqfmt' => '',
229
+ 'bafmt' => ''
230
+ }
231
+ end
232
+
233
+ def cloze_template
234
+ {
235
+ 'name' => 'Cloze',
236
+ 'ord' => 0,
237
+ 'qfmt' => '{{cloze:Text}}',
238
+ 'afmt' => '{{cloze:Text}}',
239
+ 'did' => nil,
240
+ 'bqfmt' => '',
241
+ 'bafmt' => ''
242
+ }
243
+ end
244
+
245
+ def latex_pre
246
+ <<~LATEX
247
+ \\documentclass[12pt]{article}
248
+ \\special{papersize=3in,5in}
249
+ \\usepackage[utf8]{inputenc}
250
+ \\usepackage{amssymb,amsmath}
251
+ \\pagestyle{empty}
252
+ \\setlength{\\parindent}{0in}
253
+ \\begin{document}
254
+ LATEX
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'securerandom'
6
+ require 'sqlite3'
7
+ require 'tmpdir'
8
+ require 'zip'
9
+ require_relative 'apkg_schema'
10
+
11
+ module AnkiGenerator
12
+ # Builds a valid Anki 2.1 .apkg file: a zip archive containing a SQLite
13
+ # collection database. Replaces the unmaintained `anki2` gem and gives us
14
+ # full control over the generated schema (see ApkgSchema for the definition).
15
+ class ApkgWriter
16
+ CardEntry = Struct.new(:front, :back, :tags, :cloze)
17
+
18
+ def initialize(name:, output_path:)
19
+ @name = name
20
+ @output_path = output_path
21
+ @cards = []
22
+ # Note and card ids must each be unique within their own table; simple
23
+ # monotonic sequences starting at the current millisecond timestamp work
24
+ # fine and keep cloze expansions collision-free.
25
+ @note_seq = (Time.now.to_f * 1000).to_i
26
+ @card_seq = @note_seq
27
+ end
28
+
29
+ # A basic card is a front/back pair; pass `cloze:` instead for an Anki
30
+ # cloze note (a single text with {{cN::...}} deletions). One card row is
31
+ # emitted per distinct cloze ordinal.
32
+ def add_card(front, back, tags: [], cloze: nil)
33
+ @cards << CardEntry.new(front.to_s, back.to_s, Array(tags).map(&:to_s), cloze&.to_s)
34
+ end
35
+
36
+ def save
37
+ Dir.mktmpdir('anki_generator') do |dir|
38
+ db_path = File.join(dir, 'collection.anki2')
39
+ build_database(db_path)
40
+ write_archive(db_path, File.join(dir, 'media'))
41
+ end
42
+ @output_path
43
+ end
44
+
45
+ private
46
+
47
+ def build_database(db_path)
48
+ SQLite3::Database.new(db_path) do |db|
49
+ # The insert helpers below all operate on this connection, which lives
50
+ # only for the duration of `save`; keeping it in an ivar keeps every
51
+ # insert signature at a readable arity.
52
+ @db = db
53
+ db.execute_batch(ApkgSchema::SQL)
54
+ insert_collection
55
+ @cards.each_with_index { |card, index| insert_note(card, index) }
56
+ end
57
+ end
58
+
59
+ def insert_collection
60
+ @db.execute(
61
+ 'INSERT INTO col (id, crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags) ' \
62
+ 'VALUES (1, ?, ?, ?, ?, 0, 0, 0, ?, ?, ?, ?, ?)',
63
+ [now_seconds, now_millis, now_millis, ApkgSchema::SCHEMA_VERSION,
64
+ ApkgSchema.conf_json,
65
+ ApkgSchema.models_json(now_seconds:),
66
+ ApkgSchema.decks_json(deck_name: @name, now_seconds:),
67
+ ApkgSchema.dconf_json, '{}']
68
+ )
69
+ end
70
+
71
+ def insert_note(card, index)
72
+ if card.cloze && !card.cloze.empty?
73
+ insert_cloze_note(card, index)
74
+ else
75
+ insert_basic_note(card, index)
76
+ end
77
+ end
78
+
79
+ def insert_basic_note(card, index)
80
+ note_id = next_note_id
81
+ fields = "#{card.front}\x1F#{card.back}"
82
+
83
+ insert_note_row(note_id, ApkgSchema::MODEL_ID, fields, card.front, card.tags)
84
+ insert_card_row(next_card_id, note_id, ord: 0, due: index)
85
+ end
86
+
87
+ def insert_cloze_note(card, index)
88
+ note_id = next_note_id
89
+ ords = cloze_ords(card.cloze)
90
+
91
+ insert_note_row(note_id, ApkgSchema::CLOZE_MODEL_ID, card.cloze, plain_text(card.cloze), card.tags)
92
+ ords.each_with_index do |ord, offset|
93
+ insert_card_row(next_card_id, note_id, ord:, due: index + offset)
94
+ end
95
+ end
96
+
97
+ def insert_note_row(note_id, model_id, fields, sort_field, tags)
98
+ checksum = Digest::SHA1.hexdigest(sort_field)[0, 8].to_i(16)
99
+
100
+ @db.execute(
101
+ 'INSERT INTO notes (id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data) ' \
102
+ 'VALUES (?, ?, ?, ?, -1, ?, ?, ?, ?, 0, ?)',
103
+ [note_id, SecureRandom.alphanumeric(10), model_id, now_seconds, tags.join(' '), fields, sort_field,
104
+ checksum, '']
105
+ )
106
+ end
107
+
108
+ def insert_card_row(card_id, note_id, ord:, due:)
109
+ @db.execute(
110
+ 'INSERT INTO cards (id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, ' \
111
+ 'left, odue, odid, flags, data) VALUES (?, ?, ?, ?, ?, -1, 0, 0, ?, 0, 0, 0, 0, 0, 0, 0, 0, ?)',
112
+ [card_id, note_id, ApkgSchema::DECK_ID, ord, now_seconds, due, '']
113
+ )
114
+ end
115
+
116
+ # Distinct 1-based cloze ordinals, e.g. "{{c1::a}} {{c2::b}}" → [0, 1]
117
+ # (card ord is zero-based; ordinal 1 → ord 0).
118
+ def cloze_ords(cloze_text)
119
+ cloze_text.scan(/\{\{c(\d+)::/).flatten.map(&:to_i).uniq.sort.map { |n| n - 1 }
120
+ end
121
+
122
+ def plain_text(cloze_text)
123
+ cloze_text.gsub(/\{\{c\d+::(.+?)\}\}/, '\1').gsub(/\{\{.+?\}\}/, '').strip
124
+ end
125
+
126
+ def next_note_id
127
+ @note_seq += 1
128
+ end
129
+
130
+ def next_card_id
131
+ @card_seq += 1
132
+ end
133
+
134
+ def write_archive(db_path, media_path)
135
+ File.write(media_path, '{}')
136
+ # Re-saving over an existing .apkg must replace it, not append duplicate
137
+ # zip entries.
138
+ FileUtils.rm_f(@output_path)
139
+
140
+ Zip::File.open(@output_path, Zip::File::CREATE) do |zip|
141
+ zip.add('collection.anki2', db_path)
142
+ zip.add('media', media_path)
143
+ end
144
+ end
145
+
146
+ def now_seconds = Time.now.to_i
147
+ def now_millis = (Time.now.to_f * 1000).to_i
148
+ end
149
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module AnkiGenerator
6
+ # A single flashcard. Cards are value objects: immutable and compared by content.
7
+ #
8
+ # Two card types are supported:
9
+ # - basic: a front/back question-answer pair
10
+ # - cloze: a single sentence with {{cN::hidden text}} deletions (Anki cloze)
11
+ class Card
12
+ CLOZE_PATTERN = /\{\{c\d+::.+?\}\}/
13
+
14
+ attr_reader :front, :back, :tags
15
+
16
+ def initialize(front: nil, back: nil, tags: [], cloze: nil)
17
+ @tags = Array(tags).map { |tag| tag.to_s.strip }.reject(&:empty?)
18
+ @cloze = cloze&.to_s&.strip
19
+
20
+ if cloze?
21
+ @front = @cloze
22
+ @back = ''
23
+ validate_cloze!
24
+ else
25
+ @front = front.to_s.strip
26
+ @back = back.to_s.strip
27
+ validate!
28
+ end
29
+ end
30
+
31
+ def cloze?
32
+ !@cloze.nil? && !@cloze.empty?
33
+ end
34
+
35
+ # The distinct cloze ordinals (1-based) used by this card, e.g. [1, 2].
36
+ def cloze_indices
37
+ @cloze.to_s.scan(/\{\{c(\d+)::/).flatten.map(&:to_i).uniq.sort
38
+ end
39
+
40
+ # Plain-text version of the cloze text (markers stripped) — used for the
41
+ # note's sort field.
42
+ def plain_text
43
+ @cloze.to_s.gsub(/\{\{c\d+::(.+?)\}\}/, '\1').gsub(/\{\{.+?\}\}/, '').strip
44
+ end
45
+
46
+ def reversed
47
+ raise ValidationError, 'Cloze cards cannot be reversed' if cloze?
48
+
49
+ Card.new(front: back, back: front, tags:)
50
+ end
51
+
52
+ def to_h
53
+ hash = { 'front' => front, 'back' => back }
54
+ hash['tags'] = tags unless tags.empty?
55
+ hash['cloze'] = @cloze if cloze?
56
+ hash
57
+ end
58
+
59
+ def ==(other)
60
+ other.is_a?(Card) && front == other.front && back == other.back &&
61
+ tags == other.tags && @cloze == other.instance_variable_get(:@cloze)
62
+ end
63
+ alias eql? ==
64
+
65
+ def hash
66
+ [front, back, tags, @cloze].hash
67
+ end
68
+
69
+ private
70
+
71
+ def validate!
72
+ raise ValidationError, 'Flashcard front cannot be empty' if front.empty?
73
+ raise ValidationError, 'Flashcard back cannot be empty' if back.empty?
74
+ end
75
+
76
+ def validate_cloze!
77
+ raise ValidationError, 'Cloze text cannot be empty' if @cloze.empty?
78
+ return if CLOZE_PATTERN.match?(@cloze)
79
+
80
+ raise ValidationError, "Cloze text must contain at least one {{cN::...}} deletion, got: #{@cloze}"
81
+ end
82
+ end
83
+ end