anki_record 0.3.1 → 0.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +0 -1
  3. data/.rubocop.yml +2 -7
  4. data/CHANGELOG.md +13 -1
  5. data/Gemfile +4 -0
  6. data/Gemfile.lock +9 -2
  7. data/README.md +79 -132
  8. data/anki_record.gemspec +2 -2
  9. data/lib/anki_record/anki21_database/anki21_database.rb +138 -0
  10. data/lib/anki_record/anki21_database/anki21_database_attributes.rb +31 -0
  11. data/lib/anki_record/anki21_database/anki21_database_constructors.rb +52 -0
  12. data/lib/anki_record/anki2_database/anki2_database.rb +44 -0
  13. data/lib/anki_record/anki_package/anki_package.rb +110 -176
  14. data/lib/anki_record/card/card.rb +17 -33
  15. data/lib/anki_record/card/card_attributes.rb +3 -34
  16. data/lib/anki_record/card_template/card_template.rb +2 -2
  17. data/lib/anki_record/card_template/card_template_attributes.rb +11 -11
  18. data/lib/anki_record/collection/collection.rb +20 -154
  19. data/lib/anki_record/collection/collection_attributes.rb +2 -30
  20. data/lib/anki_record/deck/deck.rb +11 -10
  21. data/lib/anki_record/deck/deck_attributes.rb +6 -8
  22. data/lib/anki_record/deck/deck_defaults.rb +1 -1
  23. data/lib/anki_record/deck_options_group/deck_options_group.rb +8 -6
  24. data/lib/anki_record/deck_options_group/deck_options_group_attributes.rb +5 -7
  25. data/lib/anki_record/helpers/anki_guid_helper.rb +20 -0
  26. data/lib/anki_record/media/media.rb +36 -0
  27. data/lib/anki_record/note/note.rb +62 -88
  28. data/lib/anki_record/note/note_attributes.rb +19 -18
  29. data/lib/anki_record/note_field/note_field.rb +3 -3
  30. data/lib/anki_record/note_field/note_field_attributes.rb +9 -9
  31. data/lib/anki_record/note_type/note_type.rb +13 -14
  32. data/lib/anki_record/note_type/note_type_attributes.rb +17 -21
  33. data/lib/anki_record/version.rb +1 -1
  34. metadata +11 -6
  35. data/lib/anki_record/helpers/data_query_helper.rb +0 -15
@@ -2,236 +2,170 @@
2
2
 
3
3
  require "pathname"
4
4
 
5
+ require_relative "../anki2_database/anki2_database"
6
+ require_relative "../anki21_database/anki21_database"
7
+ require_relative "../media/media"
5
8
  require_relative "../card/card"
6
9
  require_relative "../collection/collection"
7
10
  require_relative "../note/note"
8
11
  require_relative "../database_setup_constants"
9
12
 
10
- # rubocop:disable Metrics/ClassLength
11
13
  module AnkiRecord
12
14
  ##
13
- # AnkiPackage represents an Anki package deck file.
15
+ # AnkiPackage represents the Anki deck package file which has the .apkg file extension
16
+ #
17
+ # This is a zip file containing two SQLite databases (collection.anki21 and collection.anki2),
18
+ # a media file, and possibly the media (images and sound files). The gem currently does not
19
+ # have any support for adding or changing media in the Anki package.
14
20
  class AnkiPackage
15
- include AnkiRecord::Helpers::DataQueryHelper
21
+ attr_accessor :anki21_database, :anki2_database, :media, :tmpdir, :tmpfiles, :target_directory, :name # :nodoc:
16
22
 
17
23
  ##
18
- # The package's collection object.
19
- attr_reader :collection
24
+ # Creates a new Anki package file (see the README)
25
+ def self.create(name:, target_directory: Dir.pwd, &closure)
26
+ anki_package = new
27
+ anki_package.create_initialize(name:, target_directory:, &closure)
28
+ anki_package
29
+ end
20
30
 
21
- ##
22
- # Instantiates a new Anki package object.
23
- #
24
- # See the README for usage details.
25
- def initialize(name:, target_directory: Dir.pwd, data: nil, open_path: nil, &closure)
26
- check_name_argument_is_valid(name:)
27
- @name = name.end_with?(".apkg") ? name[0, name.length - 5] : name
31
+ def create_initialize(name:, target_directory: Dir.pwd, &closure) # :nodoc:
32
+ validate_arguments(name:, target_directory:)
33
+ @name = new_apkg_name(name:)
28
34
  @target_directory = target_directory
29
- @open_path = open_path
30
- check_directory_argument_is_valid
31
- setup_other_package_instance_variables
32
- insert_existing_data(data: data) if data
35
+ @tmpdir = Dir.mktmpdir
36
+ @tmpfiles = [Anki21Database::FILENAME, Anki2Database::FILENAME, Media::FILENAME]
37
+ @anki21_database = Anki21Database.create_new(anki_package: self)
38
+ @anki2_database = Anki2Database.create_new(anki_package: self)
39
+ @media = Media.create_new(anki_package: self)
33
40
 
34
- execute_closure_and_zip(collection, &closure) if block_given?
41
+ execute_closure_and_zip(anki21_database, &closure) if closure
35
42
  end
36
43
 
37
- # Returns an SQLite3::Statement object representing the given SQL and coupled to the collection.anki21 database.
38
- #
39
- # The Statement is executed using Statement#execute (see sqlite3 gem).
40
- def prepare(sql)
41
- @anki21_database.prepare sql
44
+ ##
45
+ # Opens an existing Anki package file to update its contents (see the README)
46
+ def self.update(path:, &closure)
47
+ anki_package = new
48
+ anki_package.update_initialize(path:, &closure)
49
+ anki_package
42
50
  end
43
51
 
44
- private
45
-
46
- def execute_closure_and_zip(collection, &closure)
47
- closure.call(collection)
48
- rescue StandardError => e
49
- destroy_temporary_directory
50
- puts_error_and_standard_message(error: e)
51
- else
52
- zip
53
- end
54
-
55
- def setup_other_package_instance_variables
56
- @tmpdir = Dir.mktmpdir
57
- @tmp_files = []
58
- @anki21_database = setup_anki21_database_object
59
- @anki2_database = setup_anki2_database_object
60
- @media_file = setup_media
61
- @collection = Collection.new(anki_package: self)
62
- end
52
+ def update_initialize(path:, &closure) # :nodoc:
53
+ validate_path(path:)
63
54
 
64
- def check_name_argument_is_valid(name:)
65
- return if name.instance_of?(String) && !name.empty? && !name.include?(" ")
55
+ @tmpdir = Dir.mktmpdir
56
+ unzip_apkg_into_tmpdir(path:)
57
+ @tmpfiles = [Anki21Database::FILENAME, Anki2Database::FILENAME, Media::FILENAME]
58
+ @anki21_database = Anki21Database.update_new(anki_package: self)
59
+ @anki2_database = Anki2Database.update_new(anki_package: self)
60
+ @media = Media.update_new(anki_package: self)
66
61
 
67
- raise ArgumentError, "The package name must be a string without spaces."
68
- end
62
+ @updating_existing_apkg = true
63
+ execute_closure_and_zip(anki21_database, &closure) if closure
64
+ end
69
65
 
70
- def check_directory_argument_is_valid
71
- raise ArgumentError, "No directory was found at the given path." unless File.directory?(@target_directory)
72
- end
66
+ # :nodoc:
67
+ def zip
68
+ @updating_existing_apkg ? replace_zip_file : create_zip_file
69
+ destroy_temporary_directory
70
+ end
73
71
 
74
- def setup_anki21_database_object
75
- anki21_file_name = "collection.anki21"
76
- db = SQLite3::Database.new "#{@tmpdir}/#{anki21_file_name}", options: {}
77
- @tmp_files << anki21_file_name
78
- db.execute_batch ANKI_SCHEMA_DEFINITION
79
- db.execute INSERT_COLLECTION_ANKI_21_COL_RECORD
80
- db.results_as_hash = true
81
- db
82
- end
72
+ # :nocov:
73
+ def inspect
74
+ "[= AnkiPackage name: #{name} target_directory: #{target_directory} =]"
75
+ end
76
+ # :nocov:
83
77
 
84
- def setup_anki2_database_object
85
- anki2_file_name = "collection.anki2"
86
- db = SQLite3::Database.new "#{@tmpdir}/#{anki2_file_name}", options: {}
87
- @tmp_files << anki2_file_name
88
- db.execute_batch ANKI_SCHEMA_DEFINITION
89
- db.execute INSERT_COLLECTION_ANKI_2_COL_RECORD
90
- db.close
91
- db
92
- end
78
+ private
93
79
 
94
- def setup_media
95
- media_file_path = FileUtils.touch("#{@tmpdir}/media")[0]
96
- media_file = File.open(media_file_path, mode: "w")
97
- media_file.write("{}")
98
- media_file.close
99
- @tmp_files << "media"
100
- media_file
101
- end
80
+ def validate_path(path:)
81
+ pathname = Pathname.new(path)
82
+ raise "*No .apkg file was found at the given path." unless pathname.file? && pathname.extname == ".apkg"
102
83
 
103
- def insert_existing_data(data:)
104
- @collection.copy_over_existing(col_record: data[:col_record])
105
- copy_over_notes_and_cards(note_ids: data[:note_ids])
84
+ @name = File.basename(pathname.to_s, ".apkg")
85
+ @target_directory = pathname.expand_path.dirname.to_s
106
86
  end
107
87
 
108
- def copy_over_notes_and_cards(note_ids:)
109
- temporarily_unzip_source_apkg do |source_collection_anki21|
110
- note_ids.each do |note_id|
111
- note_cards_data = note_cards_data_for_note_id(sql_able: source_collection_anki21, id: note_id)
112
- AnkiRecord::Note.new(collection: @collection, data: note_cards_data).save
88
+ def unzip_apkg_into_tmpdir(path:)
89
+ Zip::File.open(path) do |zip_file|
90
+ zip_file.each do |entry|
91
+ entry.extract("#{tmpdir}/#{entry.name}")
113
92
  end
114
93
  end
115
94
  end
116
95
 
117
- def standard_error_thrown_in_block_message
118
- "Any temporary files created have been deleted.\nNo new *.apkg zip file was saved."
96
+ def validate_arguments(name:, target_directory:)
97
+ check_name_argument_is_valid(name:)
98
+ check_target_directory_argument_is_valid(target_directory:)
119
99
  end
120
100
 
121
- def puts_error_and_standard_message(error:)
122
- puts error.backtrace
123
- puts "#{error}\n#{standard_error_thrown_in_block_message}"
124
- end
125
-
126
- public
101
+ def check_name_argument_is_valid(name:)
102
+ return if name.instance_of?(String) && !name.empty? && !name.include?(" ")
127
103
 
128
- ##
129
- # Instantiates a new Anki package object seeded with data from the opened Anki package.
130
- #
131
- # See the README for details.
132
- def self.open(path:, target_directory: nil, &closure)
133
- pathname = Pathname.new(path)
134
- raise "*No .apkg file was found at the given path." unless pathname.file? && pathname.extname == ".apkg"
135
-
136
- new_apkg_name = "#{File.basename(pathname.to_s, ".apkg")}-#{seconds_since_epoch}"
137
- data = col_record_and_note_ids_to_copy_over(pathname: pathname)
138
-
139
- if target_directory
140
- new(name: new_apkg_name, data: data, open_path: pathname,
141
- target_directory: target_directory, &closure)
142
- else
143
- new(name: new_apkg_name, data: data, open_path: pathname, &closure)
104
+ raise ArgumentError, "The package name must be a string without spaces."
144
105
  end
145
- end
146
-
147
- def was_instantiated_from_existing_apkg? # :nodoc:
148
- !@open_path.nil?
149
- end
150
-
151
- # rubocop:disable Metrics/MethodLength
152
- # :nodoc:
153
- def temporarily_unzip_source_apkg
154
- raise ArgumentError unless @open_path && block_given?
155
106
 
156
- Zip::File.open(@open_path) do |zip_file|
157
- zip_file.each do |entry|
158
- next unless entry.name == "collection.anki21"
107
+ def check_target_directory_argument_is_valid(target_directory:)
108
+ return if File.directory?(target_directory)
159
109
 
160
- entry.extract
161
- source_collection_anki21 = SQLite3::Database.open "collection.anki21"
162
- source_collection_anki21.results_as_hash = true
163
-
164
- yield source_collection_anki21
165
- end
110
+ raise ArgumentError, "No directory was found at the given path."
166
111
  end
167
- File.delete("collection.anki21")
168
- end
169
- # rubocop:enable Metrics/MethodLength
170
112
 
171
- class << self
172
- include Helpers::TimeHelper
113
+ def new_apkg_name(name:)
114
+ name.end_with?(".apkg") ? name[0, name.length - 5] : name
115
+ end
173
116
 
174
117
  # rubocop:disable Metrics/MethodLength
175
- # rubocop:disable Metrics/AbcSize
176
- def col_record_and_note_ids_to_copy_over(pathname:) # :nodoc:
177
- data = {}
178
- Zip::File.open(pathname) do |zip_file|
179
- zip_file.each do |entry|
180
- next unless entry.name == "collection.anki21"
181
-
182
- entry.extract
183
- source_collection_anki21 = SQLite3::Database.open "collection.anki21"
184
- source_collection_anki21.results_as_hash = true
185
- col_record = source_collection_anki21.prepare("select * from col").execute.first
186
- note_ids = source_collection_anki21.prepare("select id from notes").execute.map { |note| note["id"] }
187
- data = { col_record: col_record, note_ids: note_ids }
188
- end
189
- end
190
- File.delete("collection.anki21")
191
- data
118
+ def execute_closure_and_zip(anki21_database)
119
+ yield(anki21_database)
120
+ rescue StandardError => e
121
+ destroy_temporary_directory
122
+ puts e.backtrace.reverse
123
+ puts e
124
+ puts "An error occurred within the block argument."
125
+ puts "The temporary files have been deleted."
126
+ puts "If you were creating a new Anki package, nothing was saved."
127
+ puts "If you were updating an existing one, it was not changed."
128
+ else
129
+ zip
192
130
  end
193
- # rubocop:enable Metrics/AbcSize
194
131
  # rubocop:enable Metrics/MethodLength
195
- end
196
-
197
- ##
198
- # Zips the temporary files (collection.anki21, collection.anki2, and media) into a new *.apkg package file.
199
- #
200
- # The temporary files, and the temporary directory they were in, are deleted after zipping.
201
- def zip
202
- create_zip_file && destroy_temporary_directory
203
- end
204
132
 
205
- private
133
+ def destroy_temporary_directory
134
+ FileUtils.rm_rf(tmpdir)
135
+ end
206
136
 
207
137
  def create_zip_file
208
138
  Zip::File.open(target_zip_file, create: true) do |zip_file|
209
- @tmp_files.each do |file_name|
210
- zip_file.add(file_name, File.join(@tmpdir, file_name))
139
+ tmpfiles.each do |file_name|
140
+ zip_file.add(file_name, File.join(tmpdir, file_name))
211
141
  end
212
142
  end
213
143
  true
214
144
  end
215
145
 
216
- def target_zip_file
217
- "#{@target_directory}/#{@name}.apkg"
146
+ # rubocop:disable Metrics/MethodLength
147
+ def replace_zip_file
148
+ File.rename(target_zip_file, tmp_original_zip_file)
149
+ begin
150
+ create_zip_file
151
+ FileUtils.rm(tmp_original_zip_file)
152
+ rescue StandardError => e
153
+ puts e.backtrace.reverse
154
+ puts e
155
+ puts "An error occurred during zipping the new version of the Anki package."
156
+ puts "The original package has not been changed"
157
+ File.rename(tmp_original_zip_file, target_zip_file)
158
+ end
159
+ true
218
160
  end
161
+ # rubocop:enable Metrics/MethodLength
219
162
 
220
- def destroy_temporary_directory
221
- FileUtils.rm_rf(@tmpdir)
163
+ def target_zip_file
164
+ "#{target_directory}/#{name}.apkg"
222
165
  end
223
166
 
224
- public
225
-
226
- # :nodoc:
227
- def open?
228
- !closed?
229
- end
230
-
231
- # :nodoc:
232
- def closed?
233
- @anki21_database.closed?
234
- end
167
+ def tmp_original_zip_file
168
+ "#{target_zip_file}-old"
169
+ end
235
170
  end
236
171
  end
237
- # rubocop:enable Metrics/ClassLength
@@ -6,39 +6,38 @@ require_relative "card_attributes"
6
6
 
7
7
  module AnkiRecord
8
8
  ##
9
- # Card represents an Anki card.
9
+ # Card represents an Anki card. The cards are indirectly created when creating notes.
10
10
  class Card
11
11
  include CardAttributes
12
12
  include Helpers::TimeHelper
13
13
  include Helpers::SharedConstantsHelper
14
14
 
15
- def initialize(note:, card_template: nil, card_data: nil) # :nodoc:
15
+ # :nodoc:
16
+
17
+ def initialize(note:, card_template: nil, card_data: nil)
16
18
  @note = note
17
19
  if card_template
18
- setup_instance_variables_for_new_card(card_template: card_template)
20
+ setup_instance_variables_for_new_card(card_template:)
19
21
  elsif card_data
20
- setup_instance_variables_from_existing(card_data: card_data)
22
+ setup_instance_variables_from_existing(card_data:)
21
23
  else
22
24
  raise ArgumentError
23
25
  end
24
26
  end
25
27
 
28
+ def save(note_exists_already: false)
29
+ note_exists_already ? update_card_in_collection_anki21 : insert_new_card_in_collection_anki21
30
+ end
31
+
26
32
  private
27
33
 
34
+ # rubocop:disable Metrics/MethodLength
28
35
  def setup_instance_variables_for_new_card(card_template:)
29
36
  raise ArgumentError unless @note.note_type == card_template.note_type
30
37
 
31
- setup_collaborator_object_instance_variables_for_new_card(card_template: card_template)
32
- setup_simple_instance_variables_for_new_card
33
- end
34
-
35
- def setup_collaborator_object_instance_variables_for_new_card(card_template:)
36
38
  @card_template = card_template
37
39
  @deck = @note.deck
38
- @collection = @deck.collection
39
- end
40
-
41
- def setup_simple_instance_variables_for_new_card
40
+ @anki21_database = @deck.anki21_database
42
41
  @id = milliseconds_since_epoch
43
42
  @last_modified_timestamp = seconds_since_epoch
44
43
  @usn = NEW_OBJECT_USN
@@ -47,37 +46,22 @@ module AnkiRecord
47
46
  end
48
47
  @data = "{}"
49
48
  end
49
+ # rubocop:enable Metrics/MethodLength
50
50
 
51
51
  def setup_instance_variables_from_existing(card_data:)
52
- setup_collaborator_object_instance_variables_from_existing(card_data: card_data)
53
- setup_simple_instance_variables_from_existing(card_data: card_data)
54
- end
55
-
56
- def setup_collaborator_object_instance_variables_from_existing(card_data:)
57
- @collection = note.note_type.collection
58
- @deck = collection.find_deck_by id: card_data["did"]
52
+ @anki21_database = note.anki21_database
53
+ @deck = anki21_database.find_deck_by id: card_data["did"]
59
54
  @card_template = note.note_type.card_templates.find do |card_template|
60
55
  card_template.ordinal_number == card_data["ord"]
61
56
  end
62
- end
63
-
64
- def setup_simple_instance_variables_from_existing(card_data:)
65
57
  @last_modified_timestamp = card_data["mod"]
66
58
  %w[id usn type queue due ivl factor reps lapses left odue odid flags data].each do |instance_variable_name|
67
59
  instance_variable_set "@#{instance_variable_name}", card_data[instance_variable_name]
68
60
  end
69
61
  end
70
62
 
71
- public
72
-
73
- def save(note_exists_already: false) # :nodoc:
74
- note_exists_already ? update_card_in_collection_anki21 : insert_new_card_in_collection_anki21
75
- end
76
-
77
- private
78
-
79
63
  def update_card_in_collection_anki21
80
- statement = @collection.anki_package.prepare <<~SQL
64
+ statement = anki21_database.prepare <<~SQL
81
65
  update cards set nid = ?, did = ?, ord = ?, mod = ?, usn = ?, type = ?,
82
66
  queue = ?, due = ?, ivl = ?, factor = ?, reps = ?, lapses = ?,
83
67
  left = ?, odue = ?, odid = ?, flags = ?, data = ? where id = ?
@@ -89,7 +73,7 @@ module AnkiRecord
89
73
  end
90
74
 
91
75
  def insert_new_card_in_collection_anki21
92
- statement = @collection.anki_package.prepare <<~SQL
76
+ statement = anki21_database.prepare <<~SQL
93
77
  insert into cards (id, nid, did, ord,
94
78
  mod, usn, type, queue,
95
79
  due, ivl, factor, reps,
@@ -1,39 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AnkiRecord
4
- ##
5
- # Module with the Card class's attribute readers, writers, and accessors.
6
- module CardAttributes
7
- ##
8
- # The card's note object.
9
- attr_reader :note
10
-
11
- ##
12
- # The card's deck object.
13
- attr_reader :deck
14
-
15
- ##
16
- # The card's collection object.
17
- attr_reader :collection
18
-
19
- ##
20
- # The card's card template object.
21
- attr_reader :card_template
22
-
23
- ##
24
- # The card's id.
25
- #
26
- # This is also the number of milliseconds since the 1970 epoch at which the card was created.
27
- attr_reader :id
28
-
29
- ##
30
- # The number of seconds since the 1970 epoch at which the card was last modified.
31
- attr_reader :last_modified_timestamp
32
-
33
- ##
34
- # The card's update sequence number.
35
- attr_reader :usn
36
-
37
- attr_reader :type, :queue, :due, :ivl, :factor, :reps, :lapses, :left, :odue, :odid, :flags, :data
4
+ module CardAttributes # :nodoc:
5
+ attr_reader :anki21_database, :note, :deck, :card_template, :id, :last_modified_timestamp, :usn, :type, :queue,
6
+ :due, :ivl, :factor, :reps, :lapses, :left, :odue, :odid, :flags, :data
38
7
  end
39
8
  end
@@ -14,9 +14,9 @@ module AnkiRecord
14
14
 
15
15
  @note_type = note_type
16
16
  if args
17
- setup_card_template_instance_variables_from_existing(args: args)
17
+ setup_card_template_instance_variables_from_existing(args:)
18
18
  else
19
- setup_card_template_instance_variables(name: name)
19
+ setup_card_template_instance_variables(name:)
20
20
  end
21
21
 
22
22
  @note_type.add_card_template self
@@ -5,25 +5,25 @@ module AnkiRecord
5
5
  # Module with the CardTemplate class's attribute readers, writers, and accessors.
6
6
  module CardTemplateAttributes
7
7
  ##
8
- # The card template's name.
8
+ # The card template's name
9
9
  attr_accessor :name
10
10
 
11
11
  ##
12
- # The card template's font style in the browser.
12
+ # The card template's font style in the browser
13
13
  attr_accessor :browser_font_style
14
14
 
15
15
  ##
16
- # The card template's font size used in the browser.
16
+ # The card template's font size used in the browser
17
17
  attr_accessor :browser_font_size
18
18
 
19
19
  ##
20
- # The card template's question format.
20
+ # The card template's question format
21
21
  attr_reader :question_format
22
22
 
23
23
  ##
24
- # Sets the question format of the card template.
24
+ # Sets the question format of the card template
25
25
  #
26
- # Raises an ArgumentError if the specified format attempts to use invalid fields.
26
+ # Raises an ArgumentError if the specified format attempts to use invalid fields
27
27
  def question_format=(format)
28
28
  fields_in_specified_format = format.scan(/{{.+?}}/).map do |capture|
29
29
  capture.chomp("}}").reverse.chomp("{{").reverse
@@ -38,13 +38,13 @@ module AnkiRecord
38
38
  end
39
39
 
40
40
  ##
41
- # The card template's answer format.
41
+ # The card template's answer format
42
42
  attr_reader :answer_format
43
43
 
44
44
  ##
45
- # Sets the answer format of the card template.
45
+ # Sets the answer format of the card template
46
46
  #
47
- # Raises an ArgumentError if the specified format attempts to use invalid fields.
47
+ # Raises an ArgumentError if the specified format attempts to use invalid fields
48
48
  def answer_format=(format)
49
49
  fields_in_specified_format = format.scan(/{{.+?}}/).map do |capture|
50
50
  capture.chomp("}}").reverse.chomp("{{").reverse
@@ -59,11 +59,11 @@ module AnkiRecord
59
59
  end
60
60
 
61
61
  ##
62
- # The card template's note type object.
62
+ # The card template's note type object
63
63
  attr_reader :note_type
64
64
 
65
65
  ##
66
- # 0 for the first card template of the note type, 1 for the second, etc.
66
+ # 0 for the first card template of the note type, 1 for the second, etc
67
67
  attr_reader :ordinal_number
68
68
  end
69
69
  end