anki_record 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: 6b6bcf30a94ecdc25ac6632007ec16125c92ee8a35a13d93dfbe61b721a5564c
4
- data.tar.gz: d3ad70a4966a2fb260447e44d6da5f33fb799a4559a5147d8deb554ea6fbe5c1
3
+ metadata.gz: 060b1e641776c8bf67bc20fa50029c1801ca7b367e2e038b2aa9b3024cd7b22b
4
+ data.tar.gz: 21a27f8b9d0726122bc2ba22eb6132573e161d30d73e4db866f081db87ea8409
5
5
  SHA512:
6
- metadata.gz: 8ff665346db80061992e832cada84a373b22bd480bfbbbf3e1deab9ca2e3915d4a668442bccee98de746ed1a636ab6a978bc5ef0c61e640908a4df5fe0c57bf3
7
- data.tar.gz: 563d4c9984ab0c98af1565748904263902c9fa374af61e3882b8c618dcb471856f2d36137e8590489354148b13c1492f94e9744970addf5e8acad07f40efce17
6
+ metadata.gz: 8fdf8b0330814f95a11dffba1fbf2d113aa97269a8eba51d3675d7a8a761e37148dc5561b5fd96eeb9c94fd8b416ae00ade70acf88b20a0e5f7a9a668c4a8a7b
7
+ data.tar.gz: de6e49c1acbb6f56b952f682be94affaa8beecfcea79fb473fed9b53f0970e40394302b48a2b2cc167315589d8200669a13f5f4721ac184a4606b8a3ef4972f2
data/CHANGELOG.md CHANGED
@@ -2,8 +2,15 @@
2
2
 
3
3
  ## [Unreleased/0.1.0] - 02-22-2023
4
4
 
5
- - First version that can be used to create a complete *.apkg file that imports into Anki
5
+ - The gem can be used to create an *.apkg zip file that successfully imports into Anki.
6
+ - Raw SQL statements can be executed against the temporary database before it is zipped.
6
7
 
7
8
  ## [0.1.1] - 02-24-2023
8
9
 
9
- - Updated documentation
10
+ - Updated documentation to release the first version
11
+
12
+ ## [0.2.0] - 03-05-2023
13
+
14
+ - `AnkiPackage#zip_and_close` is changed to `AnkiPackage#zip`
15
+ - Decks and note types can be accessed through the collection object
16
+ - Notes can be created, updated, and saved to the database and this also populates the corresponding records in the `cards` table
data/Gemfile.lock CHANGED
@@ -1,9 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- anki_record (0.1.1)
4
+ anki_record (0.2.0)
5
5
  rubyzip (>= 2.3)
6
- sqlite3
6
+ sqlite3 (~> 1.3)
7
7
 
8
8
  GEM
9
9
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -1,10 +1,8 @@
1
1
  # AnkiRecord
2
2
 
3
- AnkiRecord provides an interface to Anki SQLite databases through the Ruby programming language.
3
+ AnkiRecord is a Ruby gem which provides a programmatic interface to creating and updating Anki flashcard deck files (`*.apkg` Anki SQLite databases). **This gem is in an early stage of development--I do not recommend you use it yet.**
4
4
 
5
- Currently it can be used to create an empty Anki database file, execute raw SQL statements against it, and then zip the database into an *.apkg file which can be imported into Anki.
6
-
7
- [Documentation](https://kylerego.github.io/anki_record_docs)
5
+ [API Documentation](https://kylerego.github.io/anki_record_docs)
8
6
 
9
7
  ## Installation
10
8
 
@@ -18,16 +16,60 @@ If bundler is not being used to manage dependencies, install the gem by executin
18
16
 
19
17
  ## Usage
20
18
 
19
+ The Anki package object is instantiated with `AnkiRecord::AnkiPackage.new` and if passed a block, will execute the block and zip the `*.apkg` file:
20
+
21
21
  ```ruby
22
22
  require "anki_record"
23
23
 
24
- db = AnkiRecord::AnkiPackage.new name: "test1"
25
- db.execute "any valid SQL statement"
26
- db.zip_and_close # creates test.apkg file in the current working directory
24
+ AnkiRecord::AnkiPackage.new(name: "test") do |apkg|
25
+ 3.times do |number|
26
+ puts "#{3 - number}..."
27
+ end
28
+ puts "Countdown complete. Write any Ruby you want in here!"
29
+ end
30
+ ```
31
+
32
+ If an exception is raised inside the block, the temporary `collection.anki2` and `collection.anki21` databases are deleted without creating a new `*.apkg` zip file, so this is the recommended way.
27
33
 
34
+ Alternatively, if `AnkiRecord::Package::new` is not passed a block, the `zip` method must be explicitly called on the Anki package object:
35
+
36
+ ```ruby
37
+ require "anki_record"
38
+
39
+ apkg = AnkiRecord::AnkiPackage.new(name: "test")
40
+ apkg.zip
28
41
  ```
29
42
 
30
- The RSpec tests are written BDD-style as executable documentation; reading them might help to understand the gem (e.g. [anki_package_spec.rb](https://github.com/KyleRego/anki_record/blob/main/spec/anki_record/anki_package_spec.rb)).
43
+ A new Anki package object is initialized with the "Default" deck and the default note types of a new Anki collection (including "Basic" and "Cloze"). The deck and note type objects can be accessed through the `collection` attribute of the Anki package object through the `find_deck_by` and `find_note_type_by` methods by passing the `name` keyword argument:
44
+
45
+ ```ruby
46
+ require "anki_record"
47
+
48
+ apkg = AnkiRecord::AnkiPackage.new name: "test"
49
+
50
+ deck = apkg.collection.find_deck_by name: "Default"
51
+
52
+ note_type = apkg.collection.find_note_type_by name: "Basic"
53
+
54
+ note = AnkiRecord::Note.new note_type: note_type, deck: deck
55
+ note.front = "Hello"
56
+ note.back = "World"
57
+ note.save
58
+
59
+ note_type2 = apkg.collection.find_note_type_by name: "Cloze"
60
+
61
+ note2 = AnkiRecord::Note.new note_type: note_type2, deck: deck
62
+ note2.text = "Cloze {{c1::Hello}}"
63
+ note2.back_extra = "World"
64
+ note2.save
65
+
66
+ apkg.zip
67
+
68
+ ```
69
+
70
+ This example creates a `test.apkg` zip file in the current working directory, which when imported into Anki, will add one Basic note and one Cloze note.
71
+
72
+ The RSpec examples are intended to provide executable documentation, and reading them may be helpful to understand the API (e.g. [anki_package_spec.rb](https://github.com/KyleRego/anki_record/blob/main/spec/anki_record/anki_package_spec.rb)).
31
73
 
32
74
  ## Development
33
75
 
@@ -35,10 +77,21 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
35
77
 
36
78
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
37
79
 
80
+ ### Development road map:
81
+ - Work on creating and updating notes and cards to the collection.anki21 database
82
+ - Validation logic of what makes the note valid based on the note type's card templates and fields
83
+ - Work on adding media support
84
+ - Checksum for notes needs to be updated
85
+ - Work on updating and saving decks
86
+ - Work on updating and saving deck options groups
87
+ - Work on updating and saving note types including the note fields and card templates
88
+
38
89
  ### Release checklist
39
- - Bump version
40
90
  - Update changelog
41
- - Regenerate documentation
91
+ - Update usage examples
92
+ - Update and regenerate documentation
93
+ - Bump version
94
+ - Release gem
42
95
 
43
96
  <!-- ## Contributing
44
97
 
@@ -50,4 +103,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
50
103
 
51
104
  ## Code of Conduct
52
105
 
53
- Everyone interacting in the AnkiRecord project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/KyleRegoanki_record/blob/main/CODE_OF_CONDUCT.md).
106
+ Everyone interacting in the AnkiRecord project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/KyleRego/anki_record/blob/main/CODE_OF_CONDUCT.md).
data/anki_record.gemspec CHANGED
@@ -34,7 +34,7 @@ Gem::Specification.new do |spec|
34
34
  spec.require_paths = ["lib"]
35
35
 
36
36
  spec.add_dependency "rubyzip", ">= 2.3"
37
- spec.add_dependency "sqlite3"
37
+ spec.add_dependency "sqlite3", "~> 1.3"
38
38
 
39
39
  # For more information and examples about making a new gem, check out our
40
40
  # guide at: https://bundler.io/guides/creating_gem.html
@@ -3,6 +3,9 @@
3
3
  require "pry"
4
4
  require "pathname"
5
5
 
6
+ require_relative "card"
7
+ require_relative "note"
8
+
6
9
  require_relative "db/anki_schema_definition"
7
10
  require_relative "db/clean_collection2_record"
8
11
  require_relative "db/clean_collection21_record"
@@ -24,6 +27,10 @@ module AnkiRecord
24
27
 
25
28
  private_constant :NAME_ERROR_MESSAGE, :PATH_ERROR_MESSAGE, :STANDARD_ERROR_MESSAGE
26
29
 
30
+ ##
31
+ # The collection object of the package
32
+ attr_reader :collection
33
+
27
34
  ##
28
35
  # Creates a new object which represents an Anki SQLite3 database
29
36
  #
@@ -34,20 +41,11 @@ module AnkiRecord
34
41
  # which is saved in +directory+. +directory+ is the current working directory by default.
35
42
  # If the block throws a runtime error, the temporary files are deleted but the zip file is not created.
36
43
  #
37
- # When no block argument is used, #zip_and_close must be called explicitly at the end of your script.
38
- def initialize(name:, directory: Dir.pwd)
44
+ # When no block argument is used, #zip must be called explicitly at the end of your script.
45
+ def initialize(name:, directory: Dir.pwd, &closure)
39
46
  setup_package_instance_variables(name: name, directory: directory)
40
47
 
41
- return unless block_given?
42
-
43
- begin
44
- yield self
45
- rescue StandardError => e
46
- close
47
- puts_error_and_standard_message(error: e)
48
- else
49
- zip_and_close
50
- end
48
+ execute_closure_and_zip(self, &closure) if block_given?
51
49
  end
52
50
 
53
51
  ##
@@ -62,6 +60,15 @@ module AnkiRecord
62
60
 
63
61
  private
64
62
 
63
+ def execute_closure_and_zip(object_to_yield, &closure)
64
+ closure.call(object_to_yield)
65
+ rescue StandardError => e
66
+ destroy_temporary_directory
67
+ puts_error_and_standard_message(error: e)
68
+ else
69
+ zip
70
+ end
71
+
65
72
  def setup_package_instance_variables(name:, directory:)
66
73
  @name = check_name_is_valid(name: name)
67
74
  @directory = directory # TODO: check directory is valid
@@ -118,13 +125,22 @@ module AnkiRecord
118
125
  # Creates a new object which represents the Anki SQLite3 database file at +path+
119
126
  #
120
127
  # Development has focused on ::new so this method is not recommended at this time
121
- def self.open(path:, create_backup: true)
128
+ def self.open(path:, target_directory: nil, &closure)
122
129
  pathname = check_file_at_path_is_valid(path: path)
123
- copy_apkg_file(pathname: pathname) if create_backup
124
- @anki_package = new(name: pathname.basename.to_s, directory: pathname.dirname)
130
+ new_apkg_name = "#{File.basename(pathname.to_s, ".apkg")}-#{seconds_since_epoch}"
131
+
132
+ @anki_package = if target_directory
133
+ new(name: new_apkg_name, directory: target_directory)
134
+ else
135
+ new(name: new_apkg_name)
136
+ end
137
+ @anki_package.send :execute_closure_and_zip, @anki_package, &closure if block_given?
138
+ @anki_package
125
139
  end
126
140
 
127
141
  class << self
142
+ include TimeHelper
143
+
128
144
  private
129
145
 
130
146
  def check_file_at_path_is_valid(path:)
@@ -133,22 +149,17 @@ module AnkiRecord
133
149
 
134
150
  pathname
135
151
  end
136
-
137
- def copy_apkg_file(pathname:)
138
- path = pathname.to_s
139
- FileUtils.cp path, "#{path}.copy-#{Time.now.to_i}"
140
- end
141
152
  end
142
153
 
143
154
  ##
144
155
  # Zips the temporary files into the *.apkg package and deletes the temporary files.
145
- def zip_and_close
146
- zip && close
156
+ def zip
157
+ create_zip_file && destroy_temporary_directory
147
158
  end
148
159
 
149
160
  private
150
161
 
151
- def zip
162
+ def create_zip_file
152
163
  Zip::File.open(target_zip_file, create: true) do |zip_file|
153
164
  @tmp_files.each do |file_name|
154
165
  zip_file.add(file_name, File.join(@tmpdir, file_name))
@@ -161,7 +172,7 @@ module AnkiRecord
161
172
  "#{@directory}/#{@name}.apkg"
162
173
  end
163
174
 
164
- def close
175
+ def destroy_temporary_directory
165
176
  @anki21_database.close
166
177
  FileUtils.rm_rf(@tmpdir)
167
178
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pry"
4
+
5
+ require_relative "helpers/shared_constants_helper"
6
+ require_relative "helpers/time_helper"
7
+
8
+ module AnkiRecord
9
+ ##
10
+ # Card represents an Anki card.
11
+ class Card
12
+ include TimeHelper
13
+ include SharedConstantsHelper
14
+
15
+ ##
16
+ # The note that the card belongs to
17
+ attr_reader :note
18
+
19
+ ##
20
+ # The card template that the card uses
21
+ attr_reader :card_template
22
+
23
+ ##
24
+ # The id of the card, which is time it was created as the number of milliseconds since the 1970 epoch
25
+ attr_reader :id
26
+
27
+ ##
28
+ # The time that the card was last modified as the number of seconds since the 1970 epoch
29
+ attr_reader :last_modified_time
30
+
31
+ # rubocop:disable Metrics/MethodLength
32
+ # rubocop:disable Metrics/AbcSize
33
+ def initialize(note:, card_template:)
34
+ raise ArgumentError unless note && card_template && note.note_type == card_template.note_type
35
+
36
+ @note = note
37
+ @apkg = @note.deck.collection.anki_package
38
+
39
+ @card_template = card_template
40
+
41
+ @id = milliseconds_since_epoch
42
+ @last_modified_time = seconds_since_epoch
43
+ @usn = NEW_OBJECT_USN
44
+ @type = 0
45
+ @queue = 0
46
+ @due = 0
47
+ @ivl = 0
48
+ @factor = 0
49
+ @reps = 0
50
+ @lapses = 0
51
+ @left = 0
52
+ @odue = 0
53
+ @odid = 0
54
+ @flags = 0
55
+ @data = {}
56
+ end
57
+ # rubocop:enable Metrics/MethodLength
58
+ # rubocop:enable Metrics/AbcSize
59
+
60
+ ##
61
+ # Saves the card to the collection.anki21 database
62
+ def save
63
+ @apkg.execute <<~SQL
64
+ insert into cards (id, nid, did, ord,
65
+ mod, usn, type, queue,
66
+ due, ivl, factor, reps,
67
+ lapses, left, odue, odid, flags, data)
68
+ values ('#{@id}', '#{@note.id}', '#{@note.deck.id}', '#{@card_template.ordinal_number}',
69
+ '#{@last_modified_time}', '#{@usn}', '#{@type}', '#{@queue}',
70
+ '#{@due}', '#{@ivl}', '#{@factor}', '#{@reps}',
71
+ '#{@lapses}', '#{@left}', '#{@odue}', '#{@odid}', '#{@flags}', '#{@data}')
72
+ SQL
73
+ end
74
+ end
75
+ end
@@ -9,27 +9,53 @@ module AnkiRecord
9
9
  # CardTemplate represents a card template of an Anki note type
10
10
  class CardTemplate
11
11
  ##
12
- # The name of this card template
12
+ # The name of the card template
13
13
  attr_accessor :name
14
14
 
15
15
  ##
16
- # The question format
17
- attr_accessor :question_format
16
+ # The font style shown for the card template in the browser
17
+ attr_accessor :browser_font_style
18
18
 
19
19
  ##
20
- # The answer format
21
- attr_accessor :answer_format
20
+ # The font size used for the card template in the browser
21
+ attr_accessor :browser_font_size
22
22
 
23
23
  ##
24
- # The font style shown for this card template in the browser
25
- attr_accessor :browser_font_style
24
+ # The question format of the card template
25
+ attr_reader :question_format
26
26
 
27
27
  ##
28
- # The font size used for this card template in the browser
29
- attr_accessor :browser_font_size
28
+ # Sets the question format and raises an ArgumentError if the specified format uses invalid fields
29
+ def question_format=(format)
30
+ fields_in_specified_format = format.scan(/{{.+?}}/).map do |capture|
31
+ capture.chomp("}}").reverse.chomp("{{").reverse
32
+ end
33
+ raise ArgumentError if fields_in_specified_format.any? do |field_name|
34
+ !note_type.allowed_card_template_question_format_field_names.include?(field_name)
35
+ end
36
+
37
+ @question_format = format
38
+ end
39
+
40
+ ##
41
+ # The answer format of the card template
42
+ attr_reader :answer_format
43
+
44
+ ##
45
+ # Sets the answer format and raises an ArgumentError if the specified format uses invalid fields
46
+ def answer_format=(format)
47
+ fields_in_specified_format = format.scan(/{{.+?}}/).map do |capture|
48
+ capture.chomp("}}").reverse.chomp("{{").reverse
49
+ end
50
+ raise ArgumentError if fields_in_specified_format.any? do |field_name|
51
+ !note_type.allowed_card_template_answer_format_field_names.include?(field_name)
52
+ end
53
+
54
+ @answer_format = format
55
+ end
30
56
 
31
57
  ##
32
- # The note type that this card template belongs to
58
+ # The note type that the card template belongs to
33
59
  attr_reader :note_type
34
60
 
35
61
  ##
@@ -66,7 +92,7 @@ module AnkiRecord
66
92
 
67
93
  def setup_card_template_instance_variables(name:)
68
94
  @name = name
69
- @ordinal_number = @note_type.templates.length
95
+ @ordinal_number = @note_type.card_templates.length
70
96
  @question_format = ""
71
97
  @answer_format = ""
72
98
  @bqfmt = ""
@@ -75,17 +101,5 @@ module AnkiRecord
75
101
  @browser_font_style = ""
76
102
  @browser_font_size = 0
77
103
  end
78
-
79
- public
80
-
81
- ##
82
- # Returns the field names that are allowed in the answer format and question format
83
- #
84
- # These are the field_name values in {{field_name}} in those formats.
85
- #
86
- # They are equivalent to the names of the fields of the template's note type.
87
- def allowed_field_names
88
- @note_type.fields.map(&:name)
89
- end
90
104
  end
91
105
  end
@@ -15,19 +15,59 @@ module AnkiRecord
15
15
  include AnkiRecord::TimeHelper
16
16
 
17
17
  ##
18
- # An array of the collection's note type objects
18
+ # The instance of AnkiRecord::AnkiPackage that this collection object belongs to
19
+ attr_reader :anki_package
20
+
21
+ ##
22
+ # The id attribute will become, or is the same as, the primary key id of this record in the database
23
+ #
24
+ # Since there should be only one col record, this attribute should be 1
25
+ attr_reader :id
26
+
27
+ ##
28
+ # The time in milliseconds that the col record was created since the 1970 epoch
29
+ attr_reader :creation_timestamp
30
+
31
+ ##
32
+ # The last time that the col record was modified in milliseconds since the 1970 epoch
33
+ attr_reader :last_modified_time
34
+
35
+ ##
36
+ # An array of the collection's note type objects, which are instances of AnkiRecord::NoteType
19
37
  attr_reader :note_types
20
38
 
21
39
  ##
22
- # An array of the collection's deck objects
40
+ # An array of the collection's deck objects, which are instances of AnkiRecord::Deck
23
41
  attr_reader :decks
24
42
 
43
+ ##
44
+ # An array of the collection's deck options group objects, which are instances of AnkiRecord::DeckOptionsGroup
45
+ #
46
+ # These represent groups of settings that can be applied to a deck.
47
+ attr_reader :deck_options_groups
48
+
25
49
  ##
26
50
  # Instantiates the collection object for the +anki_package+
51
+ #
52
+ # The collection object represents the single record of the collection.anki21 database col table.
53
+ #
54
+ # This record stores the note types used by the notes and the decks that they belong to.
27
55
  def initialize(anki_package:)
28
56
  setup_collection_instance_variables(anki_package: anki_package)
29
57
  end
30
58
 
59
+ ##
60
+ # Find one of the collection's note types by name
61
+ def find_note_type_by(name: nil)
62
+ note_types.find { |note_type| note_type.name == name }
63
+ end
64
+
65
+ ##
66
+ # Find one of the collection's decks by name
67
+ def find_deck_by(name: nil)
68
+ decks.find { |deck| deck.name == name }
69
+ end
70
+
31
71
  private
32
72
 
33
73
  # rubocop:disable Metrics/MethodLength
@@ -35,8 +75,8 @@ module AnkiRecord
35
75
  def setup_collection_instance_variables(anki_package:)
36
76
  @anki_package = anki_package
37
77
  @id = col_record["id"]
38
- @crt = col_record["crt"]
39
- @last_modified_time = (mod = col_record["mod"]).zero? ? milliseconds_since_epoch : mod
78
+ @creation_timestamp = col_record["crt"]
79
+ @last_modified_time = col_record["mod"]
40
80
  @scm = col_record["scm"]
41
81
  @ver = col_record["ver"]
42
82
  @dty = col_record["dty"]
@@ -49,7 +89,7 @@ module AnkiRecord
49
89
  @decks = JSON.parse(col_record["decks"]).values.map do |deck_hash|
50
90
  Deck.new(collection: self, args: deck_hash)
51
91
  end
52
- @deck_option_groups = JSON.parse(col_record["dconf"]).values.map do |dconf_hash|
92
+ @deck_options_groups = JSON.parse(col_record["dconf"]).values.map do |dconf_hash|
53
93
  DeckOptionsGroup.new(collection: self, args: dconf_hash)
54
94
  end
55
95
  @tags = JSON.parse(col_record["tags"])
@@ -5,8 +5,6 @@ require "pry"
5
5
  require_relative "helpers/shared_constants_helper"
6
6
  require_relative "helpers/time_helper"
7
7
 
8
- # TODO: All instance variables should at least be readable
9
-
10
8
  module AnkiRecord
11
9
  ##
12
10
  # Deck represents an Anki deck
@@ -19,6 +17,10 @@ module AnkiRecord
19
17
 
20
18
  private_constant :DEFAULT_DECK_TODAY_ARRAY, :DEFAULT_COLLAPSED
21
19
 
20
+ ##
21
+ # The collection object that the deck belongs to
22
+ attr_reader :collection
23
+
22
24
  ##
23
25
  # The name of the deck
24
26
  attr_accessor :name
@@ -28,11 +30,21 @@ module AnkiRecord
28
30
  attr_accessor :description
29
31
 
30
32
  ##
31
- # One of many attributes that is currently read-only and needs to be documented.
32
- attr_reader :collection, :id, :last_modified_time, :deck_options_group_id
33
+ # The id of the deck
34
+ attr_reader :id
35
+
36
+ ##
37
+ # The last time the deck was modified in number of seconds since the epoch
38
+ #
39
+ # TODO: is this really supposed to be seconds? Should it be milliseconds?
40
+ attr_reader :last_modified_time
41
+
42
+ ##
43
+ # The id of the eck options/settings group that is applied to the deck
44
+ attr_reader :deck_options_group_id
33
45
 
34
46
  ##
35
- # Instantiate a new Deck
47
+ # Instantiates a new Deck object
36
48
  def initialize(collection:, name: nil, args: nil)
37
49
  raise ArgumentError unless (name && args.nil?) || (args && args["name"])
38
50
 
@@ -79,7 +91,8 @@ module AnkiRecord
79
91
  @collapsed_in_browser = DEFAULT_COLLAPSED
80
92
  @description = ""
81
93
  @dyn = NON_FILTERED_DECK_DYN
82
- @deck_options_group_id = nil # TODO
94
+ @deck_options_group_id = nil # TODO: Set id to the default deck options group?
95
+ # TODO: alternatively, if this is nil when the deck is saved, it can be set to the default options group id
83
96
  @extend_new = 0
84
97
  @extend_review = 0
85
98
  end
@@ -13,14 +13,20 @@ module AnkiRecord
13
13
  include TimeHelper
14
14
 
15
15
  ##
16
- # The name of the options group
16
+ # The collection object that the deck options group belongs to
17
+ attr_reader :collection
18
+
19
+ ##
20
+ # The name of the deck options group
17
21
  attr_accessor :name
18
22
 
19
23
  ##
20
- # One of many attributes that is currently read-only and needs to be documented.
21
- attr_reader :collection, :id, :last_modified_time, :usn, :max_taken, :auto_play, :timer, :replay_question,
22
- :new_options, :review_options, :lapse_options, :dyn, :new_mix, :new_per_day_minimum,
23
- :interday_learning_mix, :review_order, :new_sort_order, :new_gather_priority, :bury_interday_learning
24
+ # The id of the deck options group
25
+ attr_reader :id
26
+
27
+ ##
28
+ # The last time that this deck options group was modified in milliseconds since the 1970 epoch
29
+ attr_reader :last_modified_time
24
30
 
25
31
  ##
26
32
  # Instantiates a new deck options group called +name+ with defaults
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module AnkiRecord
6
+ ##
7
+ # A module for the method that calculates the checksum value of notes.
8
+ #
9
+ # This checksum is used by Anki to detect duplicates.
10
+ module ChecksumHelper
11
+ ##
12
+ # Compute the integer representation of the first 8 characters of the digest
13
+ # (calculated using the SHA-1 Secure Hash Algorithm) of the argument
14
+ # TODO: This needs to be expanded to strip HTML (except media)
15
+ # and more tests to ensure it calculates the same value as Anki does in that case
16
+ def checksum(sfld)
17
+ Digest::SHA1.hexdigest(sfld)[0...8].to_i(16).to_s
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pry"
4
+ require "securerandom"
5
+
6
+ require_relative "helpers/checksum_helper"
7
+ require_relative "helpers/time_helper"
8
+
9
+ module AnkiRecord
10
+ ##
11
+ # Note represents an Anki note
12
+ class Note
13
+ include ChecksumHelper
14
+ include TimeHelper
15
+ include SharedConstantsHelper
16
+
17
+ ##
18
+ # The id of the note
19
+ attr_reader :id
20
+
21
+ ##
22
+ # The globally unique id of the note
23
+ attr_reader :guid
24
+
25
+ ##
26
+ # The last time the note was modified in seconds since the 1970 epoch
27
+ attr_reader :last_modified_time
28
+
29
+ ##
30
+ # The tags applied to the note
31
+ #
32
+ # TODO: a setter method for the tags of the note
33
+ attr_reader :tags
34
+
35
+ ##
36
+ # The deck that the note's cards will be put into when saved
37
+ attr_reader :deck
38
+
39
+ ##
40
+ # The note type of the note
41
+ attr_reader :note_type
42
+
43
+ ##
44
+ # The card objects of the note
45
+ attr_reader :cards
46
+
47
+ ##
48
+ # Instantiate a new note for a deck and note type
49
+ # or TODO: instantiate a new object from an already existing record
50
+ # rubocop:disable Metrics/MethodLength
51
+ # rubocop:disable Metrics/AbcSize
52
+ def initialize(deck:, note_type:)
53
+ raise ArgumentError unless deck && note_type && deck.collection == note_type.collection
54
+
55
+ @apkg = deck.collection.anki_package
56
+
57
+ @id = milliseconds_since_epoch
58
+ @guid = globally_unique_id
59
+ @last_modified_time = seconds_since_epoch
60
+ @usn = NEW_OBJECT_USN
61
+ @tags = []
62
+ @deck = deck
63
+ @note_type = note_type
64
+ @field_contents = setup_field_contents
65
+ @cards = @note_type.card_templates.map { |card_template| Card.new(note: self, card_template: card_template) }
66
+ end
67
+ # rubocop:enable Metrics/MethodLength
68
+ # rubocop:enable Metrics/AbcSize
69
+
70
+ ##
71
+ # Save the note to the collection.anki21 database
72
+ def save
73
+ @apkg.execute <<~SQL
74
+ insert into notes (id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data)
75
+ values ('#{@id}', '#{@guid}', '#{note_type.id}', '#{@last_modified_time}', '#{@usn}', '#{@tags.join(" ")}', '#{field_values_separated_by_us}', '#{sort_field_value}', '#{checksum(sort_field_value)}', '0', '')
76
+ SQL
77
+ cards.each(&:save)
78
+ true
79
+ end
80
+
81
+ ##
82
+ # This overrides BasicObject#method_missing and has the effect of creating "ghost methods"
83
+ #
84
+ # Specifically, creates setter and getter ghost methods for the fields of the note's note type
85
+ #
86
+ # TODO: This should raise a NoMethodError if
87
+ # the missing method does not end with '=' and is not a field of the note type
88
+ def method_missing(method_name, field_content = nil)
89
+ method_name = method_name.to_s
90
+ return @field_contents[method_name] unless method_name.end_with?("=")
91
+
92
+ method_name = method_name.chomp("=")
93
+ valid_fields_snake_names = @field_contents.keys
94
+ unless valid_fields_snake_names.include?(method_name)
95
+ raise ArgumentError, "Valid fields for the #{note_type.name} note type are one of #{valid_fields_snake_names.join(", ")}"
96
+ end
97
+
98
+ @field_contents[method_name] = field_content
99
+ end
100
+
101
+ ##
102
+ # This allows #respond_to? to be accurate for the ghost methods created by #method_missing
103
+ def respond_to_missing?(method_name, *)
104
+ method_name = method_name.to_s
105
+ if method_name.end_with?("=")
106
+ note_type.snake_case_field_names.include?(method_name.chomp("="))
107
+ else
108
+ note_type.snake_case_field_names.include?(method_name)
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def setup_field_contents
115
+ field_contents = {}
116
+ note_type.snake_case_field_names.each do |field_name|
117
+ field_contents[field_name] = ""
118
+ end
119
+ field_contents
120
+ end
121
+
122
+ def globally_unique_id
123
+ SecureRandom.uuid.slice(5...15)
124
+ end
125
+
126
+ def field_values_separated_by_us
127
+ # The ASCII control code represented by hexadecimal 1F is the Unit Separator (US)
128
+ note_type.snake_case_field_names.map { |field_name| @field_contents[field_name] }.join("\x1F")
129
+ end
130
+
131
+ def sort_field_value
132
+ @field_contents[note_type.snake_case_sort_field_name]
133
+ end
134
+ end
135
+ end
@@ -14,17 +14,37 @@ module AnkiRecord
14
14
  DEFAULT_FIELD_DESCRIPTION = ""
15
15
  private_constant :DEFAULT_FIELD_FONT_STYLE, :DEFAULT_FIELD_FONT_SIZE, :DEFAULT_FIELD_DESCRIPTION
16
16
 
17
+ ##
18
+ # The note type that the note field belongs to
19
+ attr_reader :note_type
20
+
17
21
  ##
18
22
  # The name of the note field
19
23
  attr_accessor :name
20
24
 
21
25
  ##
22
- # One of many attributes that is readable and writeable but needs to be documented
23
- attr_accessor :sticky, :right_to_left, :font_style, :font_size, :description
26
+ # A boolean that indicates if this field is sticky when adding cards of the note type in Anki
27
+ attr_accessor :sticky
28
+
29
+ ##
30
+ # A boolean that indicates if this field should be right to left
31
+ attr_accessor :right_to_left
32
+
33
+ ##
34
+ # The font style used when editing the note field
35
+ attr_accessor :font_style
36
+
37
+ ##
38
+ # The font size used when editing the note field
39
+ attr_accessor :font_size
40
+
41
+ ##
42
+ # The description of the note field
43
+ attr_accessor :description
24
44
 
25
45
  ##
26
- # One of many attributes that is currently read-only and needs to be documented.
27
- attr_reader :note_type, :ordinal_number
46
+ # 0 for the first field of the note type, 1 for the second, etc.
47
+ attr_reader :ordinal_number
28
48
 
29
49
  ##
30
50
  # Instantiates a new field for the given note type
@@ -48,6 +68,7 @@ module AnkiRecord
48
68
  @right_to_left = args["rtl"]
49
69
  @font_style = args["font"]
50
70
  @font_size = args["size"]
71
+ @description = args["description"]
51
72
  end
52
73
 
53
74
  def setup_note_field_instance_variables(name:)
@@ -7,8 +7,6 @@ require_relative "helpers/shared_constants_helper"
7
7
  require_relative "helpers/time_helper"
8
8
  require_relative "note_field"
9
9
 
10
- # TODO: All instance variables should at least be readable
11
-
12
10
  module AnkiRecord
13
11
  ##
14
12
  # NoteType represents an Anki note type (also called a model)
@@ -19,20 +17,54 @@ module AnkiRecord
19
17
  private_constant :NEW_NOTE_TYPE_SORT_FIELD
20
18
 
21
19
  ##
22
- # The name of this note type
20
+ # The collection object that the note type belongs to
21
+ attr_reader :collection
22
+
23
+ ##
24
+ # The id of the note type
25
+ attr_reader :id
26
+
27
+ ##
28
+ # The name of the note type
23
29
  attr_accessor :name
24
30
 
25
31
  ##
26
- # true if the note type is a cloze note type and false if it is not
32
+ # A boolean that indicates if this note type is a cloze-deletion note type
27
33
  attr_accessor :cloze
28
34
 
29
35
  ##
30
- # One of many attributes that is readable and writeable but needs to be documented
31
- attr_accessor :css, :latex_preamble, :latex_postamble, :latex_svg
36
+ # The CSS styling of the note type
37
+ attr_reader :css
38
+
39
+ ##
40
+ # The LaTeX preamble of the note type
41
+ attr_reader :latex_preamble
42
+
43
+ ##
44
+ # The LaTeX postamble of the note type
45
+ attr_reader :latex_postamble
46
+
47
+ ##
48
+ # A boolean probably related to something with LaTeX and SVG.
49
+ #
50
+ # TODO: Investigate what this does
51
+ attr_reader :latex_svg
52
+
53
+ ##
54
+ # An array of the card template objects belonging to the note type
55
+ attr_reader :card_templates
32
56
 
33
57
  ##
34
- # One of many attributes that is currently read-only and needs to be documented.
35
- attr_reader :id, :templates, :fields, :deck_id
58
+ # An array of the field names of the card template
59
+ attr_reader :fields
60
+
61
+ ##
62
+ # TODO: Investigate the meaning of the deck id of a note type
63
+ attr_reader :deck_id
64
+
65
+ ##
66
+ # TODO: Investigate the meaning of tags of a note type
67
+ attr_reader :tags
36
68
 
37
69
  ##
38
70
  # Instantiates a new note type
@@ -44,19 +76,19 @@ module AnkiRecord
44
76
  if args
45
77
  setup_note_type_instance_variables_from_existing(args: args)
46
78
  else
47
- setup_note_type_instance_variables(
48
- name: name, cloze: cloze
49
- )
79
+ setup_note_type_instance_variables(name: name, cloze: cloze)
50
80
  end
51
81
  end
52
82
 
53
83
  ##
54
- # Create a new field and adds it to this note type's fields
84
+ # Creates a new field and adds it to this note type's fields
55
85
  #
56
86
  # The field is an instance of AnkiRecord::NoteField
57
87
  def new_note_field(name:)
58
- # TODO: Check if name already used by a field in this note type
59
- @fields << AnkiRecord::NoteField.new(note_type: self, name: name)
88
+ # TODO: Raise an exception if the name is already used by a field in this note type
89
+ note_field = AnkiRecord::NoteField.new(note_type: self, name: name)
90
+ @fields << note_field
91
+ note_field
60
92
  end
61
93
 
62
94
  ##
@@ -64,8 +96,65 @@ module AnkiRecord
64
96
  #
65
97
  # The card template is an instance of AnkiRecord::CardTemplate
66
98
  def new_card_template(name:)
67
- # TODO: Check if name already used by a template in this note type
68
- @templates << AnkiRecord::CardTemplate.new(note_type: self, name: name)
99
+ # TODO: Raise an exception if the name is already used by a template in this note type
100
+ card_template = AnkiRecord::CardTemplate.new(note_type: self, name: name)
101
+ @card_templates << card_template
102
+ card_template
103
+ end
104
+
105
+ ##
106
+ # Find one of the note type's card templates by name
107
+ def find_card_template_by(name:)
108
+ card_templates.find { |template| template.name == name }
109
+ end
110
+
111
+ ##
112
+ # The field names of the note type ordered by their ordinal values
113
+ def field_names_in_order
114
+ @fields.sort_by(&:ordinal_number).map(&:name)
115
+ end
116
+
117
+ ##
118
+ # The allowed field names of the note in snake_case
119
+ #
120
+ # TODO: make this more robust... what happens when the note type name has non-alphabetic characters?
121
+ def snake_case_field_names
122
+ field_names_in_order.map { |field_name| field_name.downcase.gsub(" ", "_") }
123
+ end
124
+
125
+ ##
126
+ # The name of the field used to sort notes of the note type in the Anki browser
127
+ def sort_field_name
128
+ @fields.find { |field| field.ordinal_number == @sort_field }&.name
129
+ end
130
+
131
+ ##
132
+ # The name of the sort field in snake_case
133
+ def snake_case_sort_field_name
134
+ sort_field_name.downcase.gsub(" ", "_")
135
+ end
136
+
137
+ ##
138
+ # The allowed field_name values in {{field_name}} of the note type's card templates' question format
139
+ #
140
+ # These are the note type's fields' names, and if the note type is a cloze type,
141
+ # these also include the note type's fields' names prepended with 'cloze:'.
142
+ #
143
+ # TODO: research if other special field names like e.g. 'FrontSide' are allowed
144
+ def allowed_card_template_question_format_field_names
145
+ allowed = field_names_in_order
146
+ cloze ? allowed + field_names_in_order.map { |field_name| "cloze:#{field_name}" } : allowed
147
+ end
148
+
149
+ ##
150
+ # The allowed field_name values in {{field_name}} of the note type's card templates' answer format
151
+ #
152
+ # These are the note type's fields' names, and if the note type is a cloze type,
153
+ # these also include the note type's fields' names prepended with 'cloze:'.
154
+ #
155
+ # TODO: research if other special field names like e.g. 'FrontSide' are allowed
156
+ def allowed_card_template_answer_format_field_names
157
+ allowed_card_template_question_format_field_names + ["FrontSide"]
69
158
  end
70
159
 
71
160
  private
@@ -81,7 +170,7 @@ module AnkiRecord
81
170
  @sort_field = args["sortf"]
82
171
  @deck_id = args["did"]
83
172
  @fields = args["flds"].map { |fld| NoteField.new(note_type: self, args: fld) }
84
- @templates = args["tmpls"].map { |tmpl| CardTemplate.new(note_type: self, args: tmpl) }
173
+ @card_templates = args["tmpls"].map { |tmpl| CardTemplate.new(note_type: self, args: tmpl) }
85
174
  @css = args["css"]
86
175
  @latex_preamble = args["latexPre"]
87
176
  @latex_postamble = args["latexPost"]
@@ -103,7 +192,7 @@ module AnkiRecord
103
192
  @sort_field = NEW_NOTE_TYPE_SORT_FIELD
104
193
  @deck_id = nil
105
194
  @fields = []
106
- @templates = []
195
+ @card_templates = []
107
196
  @css = default_css
108
197
  @latex_preamble = default_latex_preamble
109
198
  @latex_postamble = default_latex_postamble
@@ -114,7 +203,6 @@ module AnkiRecord
114
203
  end
115
204
  # rubocop:enable Metrics/MethodLength
116
205
 
117
- # TODO: use constant here
118
206
  def default_css
119
207
  <<-CSS
120
208
  .card {
@@ -125,7 +213,6 @@ module AnkiRecord
125
213
  CSS
126
214
  end
127
215
 
128
- # TODO: use constant here
129
216
  def default_latex_preamble
130
217
  <<-LATEX_PRE
131
218
  \\documentclass[12pt]{article}
@@ -137,7 +224,6 @@ module AnkiRecord
137
224
  LATEX_PRE
138
225
  end
139
226
 
140
- # TODO: use constant here
141
227
  def default_latex_postamble
142
228
  <<-LATEX_POST
143
229
  \\end{document}
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AnkiRecord
4
- VERSION = "0.1.1" # :nodoc:
4
+ VERSION = "0.2.0" # :nodoc:
5
5
  end
data/lib/anki_record.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
3
  require "sqlite3"
5
4
  require "zip"
6
5
 
@@ -10,10 +9,12 @@ require_relative "anki_record/version"
10
9
  ##
11
10
  # This module is the namespace for all AnkiRecord classes:
12
11
  # - AnkiPackage
12
+ # - Card
13
13
  # - CardTemplate
14
14
  # - Collection
15
15
  # - DeckOptionsGroup
16
16
  # - Deck
17
+ # - Note
17
18
  # - NoteField
18
19
  # - NoteType
19
20
  #
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: anki_record
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kyle Rego
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-02-24 00:00:00.000000000 Z
11
+ date: 2023-03-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rubyzip
@@ -28,16 +28,16 @@ dependencies:
28
28
  name: sqlite3
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - ">="
31
+ - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '0'
33
+ version: '1.3'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - ">="
38
+ - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '0'
40
+ version: '1.3'
41
41
  description: " This Ruby library, which is currently in development, will provide
42
42
  an interface to inspect, update, and create Anki SQLite3 databases (*.apkg files).\n"
43
43
  email:
@@ -46,7 +46,6 @@ executables: []
46
46
  extensions: []
47
47
  extra_rdoc_files: []
48
48
  files:
49
- - ".rdoc_options"
50
49
  - ".rspec"
51
50
  - ".rubocop.yml"
52
51
  - CHANGELOG.md
@@ -59,6 +58,7 @@ files:
59
58
  - anki_record.gemspec
60
59
  - lib/anki_record.rb
61
60
  - lib/anki_record/anki_package.rb
61
+ - lib/anki_record/card.rb
62
62
  - lib/anki_record/card_template.rb
63
63
  - lib/anki_record/collection.rb
64
64
  - lib/anki_record/db/anki_schema_definition.rb
@@ -66,8 +66,10 @@ files:
66
66
  - lib/anki_record/db/clean_collection2_record.rb
67
67
  - lib/anki_record/deck.rb
68
68
  - lib/anki_record/deck_options_group.rb
69
+ - lib/anki_record/helpers/checksum_helper.rb
69
70
  - lib/anki_record/helpers/shared_constants_helper.rb
70
71
  - lib/anki_record/helpers/time_helper.rb
72
+ - lib/anki_record/note.rb
71
73
  - lib/anki_record/note_field.rb
72
74
  - lib/anki_record/note_type.rb
73
75
  - lib/anki_record/version.rb
@@ -94,7 +96,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
94
96
  - !ruby/object:Gem::Version
95
97
  version: '0'
96
98
  requirements: []
97
- rubygems_version: 3.4.7
99
+ rubygems_version: 3.4.6
98
100
  signing_key:
99
101
  specification_version: 4
100
102
  summary: Automate Anki flashcard editing with the Ruby programming language.
data/.rdoc_options DELETED
@@ -1,27 +0,0 @@
1
- ---
2
- encoding: UTF-8
3
- static_path: []
4
- rdoc_include: []
5
- page_dir:
6
- charset: UTF-8
7
- exclude:
8
- - "~\\z"
9
- - "\\.orig\\z"
10
- - "\\.rej\\z"
11
- - "\\.bak\\z"
12
- - "\\.gemspec\\z"
13
- hyperlink_all: false
14
- line_numbers: false
15
- locale:
16
- locale_dir: locale
17
- locale_name:
18
- main_page:
19
- markup: rdoc
20
- output_decoration: true
21
- show_hash: false
22
- skip_tests: true
23
- tab_width: 8
24
- template_stylesheets: []
25
- title:
26
- visibility: :protected
27
- webcvs: