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.
- checksums.yaml +4 -4
- data/.gitignore +59 -0
- data/.rubocop.yml +79 -0
- data/.ruby-version +1 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +173 -0
- data/Gemfile +21 -0
- data/Makefile +20 -0
- data/README.md +160 -32
- data/Rakefile +174 -0
- data/anki_generator.gemspec +42 -0
- data/bin/anki_generator +3 -2
- data/docs/CI_SETUP.md +120 -0
- data/docs/architecture/current-v1.3.0.architecture.json +315 -0
- data/docs/architecture/current-v1.3.0.html +14990 -0
- data/docs/architecture/current-v1.3.0.visual-check.json +548 -0
- data/docs/architecture/phase3-proposed.architecture.json +310 -0
- data/docs/architecture/phase3-proposed.html +15001 -0
- data/docs/architecture/phase3-proposed.visual-check.json +548 -0
- data/docs/phase3-draft.md +86 -0
- data/examples/example_class.rb +13 -0
- data/examples/manual_cards.yaml +5 -0
- data/examples/study_prompt.txt +3 -0
- data/input/input.yaml.example +3 -0
- data/lib/anki_generator/anki_connect_client.rb +85 -0
- data/lib/anki_generator/apkg_schema.rb +257 -0
- data/lib/anki_generator/apkg_writer.rb +149 -0
- data/lib/anki_generator/card.rb +83 -0
- data/lib/anki_generator/cli.rb +183 -0
- data/lib/anki_generator/client_factory.rb +20 -0
- data/lib/anki_generator/commands/create_ai_template.rb +39 -0
- data/lib/anki_generator/commands/generate_deck.rb +43 -0
- data/lib/anki_generator/commands/generate_yaml.rb +63 -0
- data/lib/anki_generator/commands/import.rb +75 -0
- data/lib/anki_generator/commands/prompt_based.rb +74 -0
- data/lib/anki_generator/commands/prompt_to_deck.rb +84 -0
- data/lib/anki_generator/commands/push.rb +32 -0
- data/lib/anki_generator/commands/serve.rb +99 -0
- data/lib/anki_generator/commands/test_api.rb +33 -0
- data/lib/anki_generator/deck_builder.rb +184 -0
- data/lib/anki_generator/errors.rb +22 -0
- data/lib/anki_generator/file_processor.rb +154 -0
- data/lib/anki_generator/importers/csv.rb +52 -0
- data/lib/anki_generator/importers/markdown.rb +72 -0
- data/lib/anki_generator/prompt_builder.rb +77 -0
- data/lib/anki_generator/server.rb +98 -0
- data/lib/anki_generator/ui.rb +28 -0
- data/lib/anki_generator/version.rb +5 -0
- data/lib/anki_generator.rb +18 -114
- data/prompt.txt +5 -0
- metadata +100 -43
- data/lib/anki_cli.rb +0 -259
- data/lib/file_processor.rb +0 -156
- 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
|