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,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'dotenv/load'
5
+ require_relative 'version'
6
+ require_relative 'errors'
7
+ require_relative 'ui'
8
+ require_relative 'client_factory'
9
+ require_relative 'commands/create_ai_template'
10
+ require_relative 'commands/generate_deck'
11
+ require_relative 'commands/generate_yaml'
12
+ require_relative 'commands/import'
13
+ require_relative 'commands/prompt_to_deck'
14
+ require_relative 'commands/push'
15
+ require_relative 'commands/serve'
16
+ require_relative 'commands/test_api'
17
+
18
+ module AnkiGenerator
19
+ # Command-line interface. This class is intentionally thin: it parses
20
+ # arguments with Thor and delegates to command objects in
21
+ # AnkiGenerator::Commands, which hold the actual behaviour.
22
+ class CLI < Thor
23
+ DEFAULT_MODEL = LlmClient::DEFAULT_MODEL
24
+
25
+ def self.exit_on_failure?
26
+ true
27
+ end
28
+
29
+ class_option :provider,
30
+ type: :string, default: nil,
31
+ desc: 'LLM provider (e.g. gemini, openai, anthropic, openrouter, ollama); ' \
32
+ 'auto-resolved from the model when omitted'
33
+
34
+ desc 'generate DECK_NAME YAML_FILE OUTPUT_FILE', 'Generate an Anki .apkg deck from a YAML file'
35
+ option :api_key, type: :string, desc: 'Provider API key (defaults to the provider env var, e.g. GEMINI_API_KEY)'
36
+ option :model, type: :string, desc: 'AI model to use (provider default if omitted)'
37
+ option :sync_with, type: :string, desc: 'Existing YAML file to sync with'
38
+ option :reverse, type: :boolean, default: false, desc: 'Add reversed copy of each basic card'
39
+ option :jobs, type: :numeric, default: 1, desc: 'Parallel API calls for multi-topic generation'
40
+ def generate(deck_name, yaml_file, output_file)
41
+ run_command Commands::GenerateDeck,
42
+ deck_name:,
43
+ yaml_file:,
44
+ output_file:,
45
+ model: option_model,
46
+ sync_with: options[:sync_with],
47
+ reverse: options[:reverse],
48
+ jobs: options[:jobs],
49
+ client: build_client(required: false)
50
+ end
51
+
52
+ desc 'generate_yaml PROMPT OUTPUT_YAML', 'Generate a YAML file from a prompt using AI'
53
+ option :api_key, type: :string, desc: 'Provider API key (defaults to the provider env var, e.g. GEMINI_API_KEY)'
54
+ option :model, type: :string, desc: 'AI model to use (provider default if omitted)'
55
+ option :difficulty, type: :string, default: 'medium', desc: 'Difficulty level (easy, medium, hard)'
56
+ option :count, type: :numeric, default: 10, desc: 'Number of flashcards to generate'
57
+ option :context, type: :string, desc: 'Additional context for better generation'
58
+ option :attach, type: :array, desc: 'Attach files or directories for context'
59
+ option :prompt_file, type: :boolean, default: false, desc: 'Treat PROMPT as a file path to read from'
60
+ def generate_yaml(prompt, output_yaml)
61
+ run_command Commands::GenerateYaml,
62
+ prompt:,
63
+ output_yaml:,
64
+ model: option_model,
65
+ difficulty: options[:difficulty],
66
+ count: options[:count],
67
+ context: options[:context],
68
+ attach: options[:attach],
69
+ prompt_file: options[:prompt_file],
70
+ client: build_client
71
+ end
72
+
73
+ desc 'prompt_to_deck PROMPT DECK_NAME OUTPUT_FILE', 'Generate flashcards from prompt and create deck in one step'
74
+ option :api_key, type: :string, desc: 'Provider API key (defaults to the provider env var, e.g. GEMINI_API_KEY)'
75
+ option :model, type: :string, desc: 'AI model to use (provider default if omitted)'
76
+ option :difficulty, type: :string, default: 'medium', desc: 'Difficulty level (easy, medium, hard)'
77
+ option :count, type: :numeric, default: 10, desc: 'Number of flashcards to generate'
78
+ option :context, type: :string, desc: 'Additional context for better generation'
79
+ option :save_yaml, type: :boolean, default: false, desc: 'Save intermediate YAML file'
80
+ option :attach, type: :array, desc: 'Attach files or directories for context'
81
+ option :prompt_file, type: :boolean, default: false, desc: 'Treat PROMPT as a file path to read from'
82
+ def prompt_to_deck(prompt, deck_name, output_file)
83
+ run_command Commands::PromptToDeck,
84
+ prompt:,
85
+ deck_name:,
86
+ output_file:,
87
+ model: option_model,
88
+ difficulty: options[:difficulty],
89
+ count: options[:count],
90
+ context: options[:context],
91
+ save_yaml: options[:save_yaml],
92
+ attach: options[:attach],
93
+ prompt_file: options[:prompt_file],
94
+ client: build_client
95
+ end
96
+
97
+ desc 'import DECK_NAME INPUT_FILE OUTPUT_FILE', 'Build a deck from Markdown or CSV study notes'
98
+ option :reverse, type: :boolean, default: false, desc: 'Add reversed copy of each basic card'
99
+ long_desc 'Supported inputs: Markdown (.md, Q:/A: pairs or "- **Q** — A" bullets, headings become tags) ' \
100
+ 'and CSV (front,back[,tags]).'
101
+ def import(deck_name, input_file, output_file)
102
+ run_command Commands::Import,
103
+ deck_name:,
104
+ input_file:,
105
+ output_file:,
106
+ reverse: options[:reverse]
107
+ end
108
+
109
+ desc 'push DECK_NAME YAML_FILE', 'Push a YAML card file into a running Anki via AnkiConnect'
110
+ option :url, type: :string, default: AnkiConnectClient::DEFAULT_URL, desc: 'AnkiConnect URL'
111
+ long_desc 'Requires Anki running with the AnkiConnect add-on. Cards are added to the Basic note type; ' \
112
+ 'duplicates are skipped.'
113
+ def push(deck_name, yaml_file)
114
+ run_command Commands::Push,
115
+ deck_name:,
116
+ yaml_file:,
117
+ url: options[:url]
118
+ end
119
+
120
+ desc 'serve', 'Start a local web UI to preview, edit, and export decks'
121
+ option :port, type: :numeric, default: Commands::Serve::DEFAULT_PORT, desc: 'Port to listen on'
122
+ long_desc 'Opens a browser UI at http://localhost:<port>: paste Markdown notes or generate cards with AI, ' \
123
+ 'edit the result, and download an .apkg.'
124
+ def serve
125
+ run_command Commands::Serve,
126
+ port: options[:port],
127
+ provider: options[:provider]
128
+ end
129
+
130
+ desc 'create_ai_template TEMPLATE_FILE', 'Create a template YAML file for AI generation'
131
+ def create_ai_template(template_file)
132
+ run_command Commands::CreateAiTemplate, template_file:
133
+ end
134
+
135
+ desc 'test_api', 'Test the LLM API connection'
136
+ option :api_key, type: :string, desc: 'Provider API key (defaults to the provider env var, e.g. GEMINI_API_KEY)'
137
+ option :model, type: :string, desc: 'AI model to use (provider default if omitted)'
138
+ def test_api
139
+ run_command Commands::TestApi, model: option_model, client: build_client
140
+ end
141
+
142
+ desc 'version', 'Show the gem version'
143
+ def version
144
+ say "anki_generator #{AnkiGenerator::VERSION}"
145
+ end
146
+
147
+ private
148
+
149
+ # All AnkiGenerator errors are reported as a clean message + exit 1;
150
+ # unexpected errors still raise with a full backtrace.
151
+ def run_command(command_class, **args)
152
+ command_class.new(**args, ui: UI.new).run
153
+ rescue AnkiGenerator::Error => e
154
+ UI.new.error("Error: #{e.message}")
155
+ exit 1
156
+ end
157
+
158
+ def option_model
159
+ options[:model] || DEFAULT_MODEL
160
+ end
161
+
162
+ # The API key comes from --api_key; otherwise the standard provider env
163
+ # vars (GEMINI_API_KEY, OPENAI_API_KEY, ...) are picked up by LlmClient
164
+ # itself. With required: false and no key anywhere, yields nil (commands
165
+ # then skip AI generation instead of failing).
166
+ def build_client(required: true)
167
+ api_key = options[:api_key]
168
+ if api_key && !api_key.empty?
169
+ return ClientFactory.build(provider: options[:provider], model: option_model, api_key:)
170
+ end
171
+
172
+ return ClientFactory.build(provider: options[:provider], model: option_model) if !required || llm_configured?
173
+
174
+ raise ConfigurationError,
175
+ 'An LLM API key is required (use --api_key or set an env var such as GOOGLE_API_KEY)'
176
+ end
177
+
178
+ def llm_configured?
179
+ (LlmClient::ENV_KEY_VARS + ['GOOGLE_API_KEY']).any? { |name| !(ENV.fetch(name, nil) || '').empty? } ||
180
+ !(ENV.fetch('OLLAMA_URL', nil) || '').empty?
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'llm_client'
4
+
5
+ module AnkiGenerator
6
+ # Builds the unified ruby_llm-backed client. Any provider ruby_llm
7
+ # supports (gemini, openai, anthropic, openrouter, ollama, ...) is valid;
8
+ # pass provider: nil to auto-resolve from the model name.
9
+ module ClientFactory
10
+ module_function
11
+
12
+ def build(model: nil, provider: nil, api_key: nil)
13
+ LlmClient.new(
14
+ model: model || LlmClient::DEFAULT_MODEL,
15
+ provider:,
16
+ api_key:
17
+ )
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../ui'
4
+
5
+ module AnkiGenerator
6
+ module Commands
7
+ # `anki_generator create_ai_template` — write a starter YAML file users can
8
+ # edit and feed back into `generate`.
9
+ class CreateAiTemplate
10
+ TEMPLATE = {
11
+ 'ai_generation' => {
12
+ 'topics' => ['Example Topic 1', 'Example Topic 2'],
13
+ 'context' => 'Additional context for generating flashcards',
14
+ 'difficulty' => 'medium',
15
+ 'count' => 5,
16
+ 'save_generated' => true
17
+ },
18
+ 'cards' => [
19
+ {
20
+ 'front' => 'Example manual card front',
21
+ 'back' => 'Example manual card back'
22
+ }
23
+ ]
24
+ }.freeze
25
+
26
+ def initialize(template_file:, ui: UI.new)
27
+ @template_file = template_file
28
+ @ui = ui
29
+ end
30
+
31
+ def run
32
+ File.write(@template_file, TEMPLATE.to_yaml)
33
+
34
+ @ui.info("AI generation template created: #{@template_file}")
35
+ @ui.info("Edit the file and run 'anki_generator generate' to create your deck!")
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../deck_builder'
4
+ require_relative '../client_factory'
5
+ require_relative '../ui'
6
+
7
+ module AnkiGenerator
8
+ module Commands
9
+ # `anki_generator generate` — build an .apkg deck from a YAML card file.
10
+ class GenerateDeck
11
+ def initialize(deck_name:, yaml_file:, output_file:, model: LlmClient::DEFAULT_MODEL,
12
+ sync_with: nil, reverse: false, jobs: 1, client: nil, ui: UI.new)
13
+ @deck_name = deck_name
14
+ @yaml_file = yaml_file
15
+ @output_file = output_file
16
+ @sync_with = sync_with
17
+ @reverse = reverse
18
+ @jobs = jobs
19
+ @client = client || build_client(model)
20
+ @ui = ui
21
+ end
22
+
23
+ def run
24
+ builder = DeckBuilder.new(name: @deck_name, deck_file: @yaml_file, client: @client, jobs: @jobs, ui: @ui)
25
+ builder.sync_with(@sync_with) if @sync_with
26
+ builder.add_reverse_cards! if @reverse
27
+ builder.generate_apkg(output_path: @output_file)
28
+
29
+ @ui.success("Anki deck '#{@deck_name}' has been successfully created as #{@output_file}!")
30
+ @ui.info("Total cards: #{builder.cards.length}")
31
+ end
32
+
33
+ private
34
+
35
+ def build_client(model)
36
+ # DeckBuilder only calls the client when the YAML has an
37
+ # ai_generation section, so building unconditionally is safe — plain
38
+ # card files never touch the network.
39
+ ClientFactory.build(model:)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'prompt_based'
4
+ require_relative '../client_factory'
5
+ require_relative '../ui'
6
+
7
+ module AnkiGenerator
8
+ module Commands
9
+ # `anki_generator generate_yaml` — generate a YAML card file from a prompt.
10
+ class GenerateYaml
11
+ include PromptBased
12
+
13
+ def initialize(prompt:, output_yaml:, model: LlmClient::DEFAULT_MODEL, difficulty: 'medium',
14
+ count: 10, context: nil, attach: nil, prompt_file: false,
15
+ client: nil, ui: UI.new)
16
+ @prompt = prompt
17
+ @output_yaml = output_yaml
18
+ @difficulty = difficulty
19
+ @count = count
20
+ @context = context
21
+ @attach = attach
22
+ @prompt_file = prompt_file
23
+ @client = client || ClientFactory.build(model:)
24
+ @ui = ui
25
+ end
26
+
27
+ def run
28
+ actual_prompt = resolve_prompt(@prompt, @prompt_file)
29
+ attachments = resolve_attachments(@attach)
30
+
31
+ announce(prompt: actual_prompt, difficulty: @difficulty, count: @count, attachments:)
32
+ cards = generate_cards(
33
+ prompt: actual_prompt, difficulty: @difficulty, count: @count,
34
+ context: @context, attachments:
35
+ )
36
+
37
+ File.write(@output_yaml, yaml_document(actual_prompt, cards).to_yaml)
38
+
39
+ @ui.success("Generated #{cards.length} flashcards!")
40
+ @ui.info("YAML file created: #{@output_yaml}")
41
+ preview_cards(cards)
42
+
43
+ @ui.info('To create an Anki deck, run:')
44
+ @ui.info(" anki_generator generate \"My Deck\" #{@output_yaml} my_deck.apkg")
45
+ end
46
+
47
+ private
48
+
49
+ def yaml_document(prompt, cards)
50
+ {
51
+ 'ai_generation' => {
52
+ 'topics' => [prompt],
53
+ 'context' => @context,
54
+ 'difficulty' => @difficulty,
55
+ 'count' => @count,
56
+ 'save_generated' => false
57
+ },
58
+ 'cards' => cards
59
+ }
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+ require_relative '../deck_builder'
5
+ require_relative '../errors'
6
+ require_relative '../importers/csv'
7
+ require_relative '../importers/markdown'
8
+ require_relative '../ui'
9
+
10
+ module AnkiGenerator
11
+ module Commands
12
+ # `anki_generator import` — build a deck straight from study notes
13
+ # (Markdown or CSV) without a YAML intermediate.
14
+ class Import
15
+ EXTENSION_IMPORTERS = {
16
+ '.md' => Importers::Markdown,
17
+ '.markdown' => Importers::Markdown,
18
+ '.csv' => Importers::Csv
19
+ }.freeze
20
+
21
+ def self.importer_for(path)
22
+ EXTENSION_IMPORTERS.fetch(File.extname(path).downcase, nil)
23
+ end
24
+
25
+ def initialize(deck_name:, input_file:, output_file:, reverse: false, ui: UI.new)
26
+ @deck_name = deck_name
27
+ @input_file = input_file
28
+ @output_file = output_file
29
+ @reverse = reverse
30
+ @ui = ui
31
+ end
32
+
33
+ def run
34
+ cards = import_cards
35
+
36
+ with_temp_deck(cards) do |deck_file|
37
+ builder = DeckBuilder.new(name: @deck_name, deck_file:, ui: @ui)
38
+ builder.add_reverse_cards! if @reverse
39
+ builder.generate_apkg(output_path: @output_file)
40
+ @ui.success("Anki deck '#{@deck_name}' has been successfully created as #{@output_file}!")
41
+ @ui.info("Total cards: #{builder.cards.length}")
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def import_cards
48
+ importer = self.class.importer_for(@input_file)
49
+ unless importer
50
+ supported = EXTENSION_IMPORTERS.keys.join(', ')
51
+ raise FileProcessingError, "Unsupported input file #{@input_file} (supported: #{supported})"
52
+ end
53
+
54
+ @ui.info("Importing #{@input_file}...")
55
+ cards = importer.parse(File.read(@input_file, encoding: 'UTF-8'))
56
+ raise FileProcessingError, "No cards found in #{@input_file}" if cards.empty?
57
+
58
+ @ui.info("Parsed #{cards.length} card(s)")
59
+ cards
60
+ rescue SystemCallError => e
61
+ raise FileProcessingError, "Cannot read #{@input_file}: #{e.message}"
62
+ end
63
+
64
+ # DeckBuilder loads from a YAML path, so stage the imported cards in a
65
+ # temp file rather than duplicating export logic.
66
+ def with_temp_deck(cards)
67
+ Tempfile.create(['anki_generator_import', '.yaml']) do |file|
68
+ file.write({ 'cards' => cards }.to_yaml)
69
+ file.flush
70
+ yield file.path
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../file_processor'
4
+ require_relative '../ui'
5
+
6
+ module AnkiGenerator
7
+ module Commands
8
+ # Shared behaviour for commands that turn a prompt (+ optional attachments)
9
+ # into flashcards via the configured LLM provider.
10
+ module PromptBased
11
+ attr_reader :client, :ui
12
+
13
+ def resolve_prompt(prompt, prompt_file)
14
+ return prompt unless prompt_file
15
+
16
+ ui.info("📄 Reading prompt from file: #{prompt}")
17
+ FileProcessor.read_prompt_from_file(prompt)
18
+ end
19
+
20
+ def resolve_attachments(attach)
21
+ return nil unless attach
22
+
23
+ ui.info('📎 Processing attachments...')
24
+ FileProcessor.process_attachments(attach, ui:)
25
+ end
26
+
27
+ def announce(prompt:, difficulty:, count:, attachments:)
28
+ preview = truncate(prompt)
29
+ ui.info("Generating flashcards for: #{preview}")
30
+ announce_parameters(difficulty:, count:, attachments:)
31
+ end
32
+
33
+ # Deck-flavored announcement used by prompt_to_deck.
34
+ def announce_deck(prompt:, difficulty:, count:, attachments:)
35
+ ui.info("🚀 Generating Anki deck from prompt: #{truncate(prompt)}")
36
+ announce_parameters(difficulty:, count:, attachments:)
37
+ ui.info('')
38
+ end
39
+
40
+ def generate_cards(prompt:, difficulty:, count:, context:, attachments:)
41
+ ui.info('Generating...')
42
+ client.generate_multiple_flashcards(
43
+ topics: [prompt],
44
+ context:,
45
+ difficulty:,
46
+ count:,
47
+ attachments:
48
+ )
49
+ end
50
+
51
+ def truncate(text)
52
+ text.length > 100 ? "#{text[0..100]}..." : text
53
+ end
54
+
55
+ def announce_parameters(difficulty:, count:, attachments:)
56
+ ui.info("Model: #{client.model}")
57
+ ui.info("Difficulty: #{difficulty}")
58
+ ui.info("Count: #{count}")
59
+ ui.info("Attachments: #{attachments&.length || 0} file(s)") if attachments
60
+ end
61
+
62
+ def preview_cards(cards, limit: 3)
63
+ ui.info('')
64
+ ui.info('Preview of generated cards:')
65
+ cards.first(limit).each_with_index do |card, index|
66
+ back = card['back'].length > 100 ? "#{card['back'][0..100]}..." : card['back']
67
+ ui.info("#{index + 1}. #{card['front']}")
68
+ ui.info(" → #{back}")
69
+ ui.info('')
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+ require_relative 'prompt_based'
5
+ require_relative '../deck_builder'
6
+ require_relative '../client_factory'
7
+ require_relative '../ui'
8
+
9
+ module AnkiGenerator
10
+ module Commands
11
+ # `anki_generator prompt_to_deck` — generate cards from a prompt and build
12
+ # the .apkg deck in one step, via a temporary YAML file that is always
13
+ # cleaned up (or persisted when --save-yaml is passed).
14
+ class PromptToDeck
15
+ include PromptBased
16
+
17
+ def initialize(prompt:, deck_name:, output_file:, model: LlmClient::DEFAULT_MODEL,
18
+ difficulty: 'medium', count: 10, context: nil, save_yaml: false,
19
+ attach: nil, prompt_file: false, client: nil, ui: UI.new)
20
+ @prompt = prompt
21
+ @deck_name = deck_name
22
+ @output_file = output_file
23
+ @difficulty = difficulty
24
+ @count = count
25
+ @context = context
26
+ @save_yaml = save_yaml
27
+ @attach = attach
28
+ @prompt_file = prompt_file
29
+ @client = client || ClientFactory.build(model:)
30
+ @ui = ui
31
+ end
32
+
33
+ def run
34
+ actual_prompt = resolve_prompt(@prompt, @prompt_file)
35
+ attachments = resolve_attachments(@attach)
36
+
37
+ announce_deck(prompt: actual_prompt, difficulty: @difficulty, count: @count, attachments:)
38
+
39
+ cards = generate_cards(
40
+ prompt: actual_prompt, difficulty: @difficulty, count: @count,
41
+ context: @context, attachments:
42
+ )
43
+ ui.success("Generated #{cards.length} flashcards!")
44
+
45
+ export_deck(cards)
46
+
47
+ ui.success("Anki deck '#{@deck_name}' created successfully!")
48
+ ui.info("File: #{@output_file}")
49
+ ui.info("Total cards: #{cards.length}")
50
+ preview_cards(cards)
51
+ end
52
+
53
+ private
54
+
55
+ def export_deck(cards)
56
+ deck_document = build_deck_document(cards)
57
+
58
+ Tempfile.create(['anki_generator', '.yaml']) do |file|
59
+ file.write(deck_document.to_yaml)
60
+ file.flush
61
+ ui.info('Creating Anki deck...')
62
+ DeckBuilder.new(name: @deck_name, deck_file: file.path, client:, ui:)
63
+ .generate_apkg(output_path: @output_file)
64
+ end
65
+
66
+ persist_yaml(deck_document) if @save_yaml
67
+ end
68
+
69
+ def persist_yaml(deck_document)
70
+ yaml_path = @output_file.sub(/\.apkg\z/, '.yaml')
71
+ File.write(yaml_path, deck_document.to_yaml)
72
+ ui.info("YAML file saved: #{yaml_path}")
73
+ end
74
+
75
+ def build_deck_document(cards)
76
+ # The cards are already generated at this point, so the intermediate
77
+ # document is plain card data. Including the ai_generation section here
78
+ # would make DeckBuilder send the prompt back through the API a second
79
+ # time instead of using the cards we already have.
80
+ { 'cards' => cards }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../anki_connect_client'
4
+ require_relative '../deck_builder'
5
+ require_relative '../ui'
6
+
7
+ module AnkiGenerator
8
+ module Commands
9
+ # `anki_generator push` — send a YAML card file straight into a running
10
+ # Anki via the AnkiConnect add-on, skipping the .apkg import step.
11
+ class Push
12
+ def initialize(deck_name:, yaml_file:, url: AnkiConnectClient::DEFAULT_URL,
13
+ anki_connect: nil, ui: UI.new)
14
+ @deck_name = deck_name
15
+ @yaml_file = yaml_file
16
+ @anki_connect = anki_connect || AnkiConnectClient.new(base_url: url)
17
+ @ui = ui
18
+ end
19
+
20
+ def run
21
+ builder = DeckBuilder.new(name: @deck_name, deck_file: @yaml_file, ui: @ui)
22
+ raise FileProcessingError, "No cards found in #{@yaml_file}" if builder.cards.empty?
23
+
24
+ @ui.info("Pushing #{builder.cards.length} card(s) to deck '#{@deck_name}' via AnkiConnect...")
25
+ result = @anki_connect.push_deck(deck_name: @deck_name, cards: builder.cards)
26
+
27
+ @ui.success("Pushed #{result[:added]} card(s) to Anki!")
28
+ @ui.info("Skipped #{result[:duplicate]} duplicate(s)") if result[:duplicate].positive?
29
+ end
30
+ end
31
+ end
32
+ end