anki_record 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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: