anki_record 0.3.2 → 0.4.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +0 -1
  3. data/.rubocop.yml +2 -7
  4. data/CHANGELOG.md +15 -1
  5. data/Gemfile +4 -0
  6. data/Gemfile.lock +9 -2
  7. data/README.md +89 -132
  8. data/anki_record.gemspec +2 -2
  9. data/lib/anki_record/anki21_database/anki21_database.rb +172 -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 +115 -174
  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 +72 -93
  28. data/lib/anki_record/note/note_attributes.rb +18 -17
  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 -7
  35. data/lib/anki_record/helpers/data_query_helper.rb +0 -15
  36. data/lib/anki_record/note/note_guid_helper.rb +0 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 909927e79a46b38b4a538e4fd0a32cf9a727ac0a9e276645fdd2296ad6d9a464
4
- data.tar.gz: 2e75c487b6345606661d746b7d945b538592bbb6bed16b882a6ce42a87892368
3
+ metadata.gz: e1a86e3c0c6ba10ebfe3867e0bdc62d5a3e8e1218fc4783c1ade86c34d790318
4
+ data.tar.gz: 662ec96ab3e29c26b0de4405b1c699ba99a6b180af5dbb2a24eb6ad4e7919cef
5
5
  SHA512:
6
- metadata.gz: aeb071caaf37a4fcd72c99f66e31b60676b412890883bf2968400bae46ac756433b0d051495a1ff34e477313ab8a296296e1fc1ecdf2b2227c241439712cef48
7
- data.tar.gz: 36e4e21fa7238736ac63239e59507042e6567869add24d09523d201b1cf606738de055484e5c21ea7b37e05a1b5872a76c8241890ad32eaea611f56a8038cbc0
6
+ metadata.gz: 9d6de87dfadcc19fe0777202f67a97d632ddaec65426d171eda9ac74d87e7d99acc2afeb964e02529a695c1409a6c0fe26a9ee026d7b6fdbfb8c2d3b284230de
7
+ data.tar.gz: c35379873392e86fe7d6a402212d84c7c8d557543b84909d24c07e1ac34a927819628f13eff335f1b2a2e0d8bc7eda6218b86dca742660838d6b2148c16e4eee
data/.rspec CHANGED
@@ -1,3 +1,2 @@
1
- --format documentation
2
1
  --color
3
2
  --require spec_helper
data/.rubocop.yml CHANGED
@@ -1,5 +1,7 @@
1
1
  require:
2
2
  - rubocop-rspec
3
+ - rubocop-rake
4
+ - rubocop-performance
3
5
 
4
6
  AllCops:
5
7
  TargetRubyVersion: 3.2.1
@@ -9,10 +11,6 @@ Style/StringLiterals:
9
11
  Enabled: true
10
12
  EnforcedStyle: double_quotes
11
13
 
12
- Style/StringLiteralsInInterpolation:
13
- Enabled: true
14
- EnforcedStyle: double_quotes
15
-
16
14
  Layout/LineLength:
17
15
  Max: 120
18
16
  # I like the rspec --format doc output to be very readable.
@@ -31,9 +29,6 @@ Layout/IndentationConsistency:
31
29
  Metrics/ClassLength:
32
30
  Max: 120
33
31
 
34
- Style/HashSyntax:
35
- EnforcedShorthandSyntax: either
36
-
37
32
  # One expectation per test is a good practice. For this test suite,
38
33
  # following this rule would have a very high performance cost.
39
34
  RSpec/MultipleExpectations:
data/CHANGELOG.md CHANGED
@@ -45,4 +45,18 @@
45
45
 
46
46
  ## [0.3.2] - 05-20-2023
47
47
  - The private `Note` method `globally_unique_id` has been moved to `NoteGuidHelper` and included into note.
48
- - The `guid` attribute also now has a public setter.
48
+ - The `guid` attribute also now has a public setter.
49
+
50
+ ## [0.4] - 07-08-2023
51
+ - `AnkiPackage.new` and `AnkiPackage.open` have been removed and replaced with `AnkiPackage.create` and `AnkiPackage.update`.
52
+ - `AnkiPackage.update` is different from `AnkiPackage.open` in that it does not create a new Anki package with a timestamp. It effectively updates the original Anki package file as long as no error is thrown.
53
+ - `Anki21Database` is yielded to the block of the above methods instead of `Collection`.
54
+ - Responsibilites of `Collection` have been reorganized to `Anki21Database`.
55
+ - The `guid` attribute of notes ic computed in a different way that allows a larger number of possible values.
56
+ - `globally_unique_id` is now a module method rather than an included instance method.
57
+
58
+ ## [0.4.1] - 08-21-2023
59
+ - `Anki21Database#find_note_by` now can take the sort field value of a note as argument.
60
+ - `Anki21Database#find_notes_by_exact_text_match` is a new method that returns an array of notes that have in any field text matching the argument.
61
+ - Trying to create an Anki package file with a path/name that already exists raises a more helpful error message.
62
+ - Saving a note to the database saves the `mod` column (last modified time) to make it easier to import an updated package into Anki.
data/Gemfile CHANGED
@@ -20,3 +20,7 @@ gem "pry"
20
20
  gem "sdoc", "~> 2.6"
21
21
 
22
22
  gem "rubocop-rspec", "~> 2.20"
23
+
24
+ gem "rubocop-rake", "~> 0.6.0"
25
+
26
+ gem "rubocop-performance", "~> 1.18"
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- anki_record (0.3.2)
4
+ anki_record (0.4.1)
5
5
  rubyzip (>= 2.3)
6
6
  sqlite3 (~> 1.3)
7
7
 
@@ -55,6 +55,11 @@ GEM
55
55
  parser (>= 3.1.1.0)
56
56
  rubocop-capybara (2.17.1)
57
57
  rubocop (~> 1.41)
58
+ rubocop-performance (1.18.0)
59
+ rubocop (>= 1.7.0, < 2.0)
60
+ rubocop-ast (>= 0.4.0)
61
+ rubocop-rake (0.6.0)
62
+ rubocop (~> 1.0)
58
63
  rubocop-rspec (2.20.0)
59
64
  rubocop (~> 1.33)
60
65
  rubocop-capybara (~> 2.17)
@@ -68,7 +73,7 @@ GEM
68
73
  simplecov_json_formatter (~> 0.1)
69
74
  simplecov-html (0.12.3)
70
75
  simplecov_json_formatter (0.1.4)
71
- sqlite3 (1.6.2-x86_64-linux)
76
+ sqlite3 (1.6.3-x86_64-linux)
72
77
  stringio (3.0.5)
73
78
  unicode-display_width (2.4.2)
74
79
 
@@ -81,6 +86,8 @@ DEPENDENCIES
81
86
  rake (~> 13.0)
82
87
  rspec (~> 3.0)
83
88
  rubocop (~> 1.21)
89
+ rubocop-performance (~> 1.18)
90
+ rubocop-rake (~> 0.6.0)
84
91
  rubocop-rspec (~> 2.20)
85
92
  sdoc (~> 2.6)
86
93
  simplecov
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Anki Record
2
2
 
3
- Anki Record is a Ruby gem providing an API to Anki flashcard deck packages (zipped SQLite databases). The main thing it does not support yet is adding media to the notes.
3
+ Anki Record is a Ruby gem to create and update Anki flashcard deck packages (files with the .apkg extension). It does not support adding media yet.
4
4
 
5
5
  ## Installation
6
6
 
@@ -14,84 +14,31 @@ If bundler is not being used to manage dependencies, install the gem by executin
14
14
 
15
15
  ## Usage
16
16
 
17
- The Anki package object is instantiated with `AnkiRecord::AnkiPackage.new`. If this is passed a block, the collection object is yielded to the block, and an Anki deck package file is created after execution of the block:
17
+ This example shows how to create a new Anki package and most of the API:
18
18
 
19
19
  ```ruby
20
20
  require "anki_record"
21
21
 
22
- AnkiRecord::AnkiPackage.new(name: "test") do |collection|
23
- 3.times do |number|
24
- puts "#{3 - number}..."
25
- end
26
- puts "Countdown complete. Write any Ruby you want in here!"
27
- end
28
- # test.apkg now exists in the current working directory.
29
- ```
30
-
31
- While execution is happening inside the block, temporary `collection.anki21` and `collection.anki2` SQLite databases and a `media` file exist inside of a temporary directory. These files are the normal zipped contents of an `*.apkg` file. `collection.anki21` is the database that the library is interacting with.
32
-
33
- If an exception is raised inside the block, the files are deleted without creating a new `*.apkg` zip file, so this is the recommended way.
34
-
35
- Alternatively, if `AnkiRecord::Package::new` is not passed a block, the `zip` method must be explicitly called on the Anki package object:
36
-
37
- ```ruby
38
- require "anki_record"
39
-
40
- apkg = AnkiRecord::AnkiPackage.new(name: "test")
41
- collection = apkg.collection
42
- # Add notes to the collection
43
- apkg.zip # This zips the temporary files into test.apkg, and then deletes them.
44
- ```
45
-
46
- The second, optional argument to `AnkiRecord::AnkiPackage.new` is `target_directory`. The default value is the current working directory, but if a relative file path argument is given, the new `*.apkg` file will be saved in that directory. An exception will be raised if the relative file path is not to a directory that exists.
47
-
48
- 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 are accessed through the `collection` attribute of the Anki package object through the `find_deck_by` and `find_note_type_by` methods passed the `name` keyword argument:
49
-
50
- ```ruby
51
- require "anki_record"
52
-
53
- AnkiRecord::AnkiPackage.new(name: "test") do |collection|
54
- deck = collection.find_deck_by name: "Default"
55
-
56
- note_type = collection.find_note_type_by name: "Basic"
57
-
58
- note = AnkiRecord::Note.new note_type: note_type, deck: deck
59
- note.front = "Hello"
60
- note.back = "World"
61
- note.save
62
-
63
- note_type2 = collection.find_note_type_by name: "Cloze"
64
-
65
- note2 = AnkiRecord::Note.new note_type: note_type2, deck: deck
66
- note2.text = "Cloze {{c1::Hello}}"
67
- note2.back_extra = "World"
68
- note2.save
69
- end
70
-
71
- ```
72
-
73
- 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.
74
-
75
- The next example shows some other features of the library:
76
-
77
- ```ruby
78
- require "anki_record"
79
-
80
- note_id = nil
81
-
82
- AnkiRecord::AnkiPackage.new(name: "test_1") do |collection|
83
- crazy_deck = AnkiRecord::Deck.new collection: collection, name: "test_1_deck"
84
- crazy_deck.save
85
-
86
- crazy_note_type = AnkiRecord::NoteType.new collection: collection, name: "test 1 note type"
87
- AnkiRecord::NoteField.new note_type: crazy_note_type, name: "crazy front"
88
- AnkiRecord::NoteField.new note_type: crazy_note_type, name: "crazy back"
89
- crazy_card_template = AnkiRecord::CardTemplate.new note_type: crazy_note_type, name: "test 1 card 1"
90
- crazy_card_template.question_format = "{{crazy front}}"
91
- crazy_card_template.answer_format = "{{crazy back}}"
92
- second_crazy_card_template = AnkiRecord::CardTemplate.new note_type: crazy_note_type, name: "test 1 card 2"
93
- second_crazy_card_template.question_format = "{{crazy back}}"
94
- second_crazy_card_template.answer_format = "{{crazy front}}"
22
+ AnkiRecord::AnkiPackage.create(name: "example") do |anki21_database|
23
+ # Creating a new deck
24
+ custom_deck = AnkiRecord::Deck.new(anki21_database:, name: "New custom deck")
25
+ custom_deck.save
26
+
27
+ # Creating a new note type
28
+ custom_note_type = AnkiRecord::NoteType.new(anki21_database:,
29
+ name: "New custom note type")
30
+ AnkiRecord::NoteField.new(note_type: custom_note_type,
31
+ name: "custom front")
32
+ AnkiRecord::NoteField.new(note_type: custom_note_type,
33
+ name: "custom back")
34
+ custom_card_template = AnkiRecord::CardTemplate.new(note_type: custom_note_type,
35
+ name: "Custom template 1")
36
+ custom_card_template.question_format = "{{custom front}}"
37
+ custom_card_template.answer_format = "{{custom back}}"
38
+ second_custom_card_template = AnkiRecord::CardTemplate.new(note_type: custom_note_type,
39
+ name: "Custom template 2")
40
+ second_custom_card_template.question_format = "{{custom back}}"
41
+ second_custom_card_template.answer_format = "{{custom front}}"
95
42
 
96
43
  css = <<~CSS
97
44
  .card {
@@ -101,83 +48,93 @@ AnkiRecord::AnkiPackage.new(name: "test_1") do |collection|
101
48
  text-align: center;
102
49
  }
103
50
  CSS
51
+ custom_note_type.css = css
52
+ custom_note_type.save
104
53
 
105
- crazy_note_type.css = css
106
- crazy_note_type.save
107
-
108
- note = AnkiRecord::Note.new note_type: crazy_note_type, deck: crazy_deck
109
- note.crazy_front = "Hello from test 1"
110
- note.crazy_back = "World"
54
+ # Creating a new note with the custom note type
55
+ note = AnkiRecord::Note.new(note_type: custom_note_type, deck: custom_deck)
56
+ note.custom_front = "Content of the 'custom front' field"
57
+ note.custom_back = "Content of the 'custom back' field"
111
58
  note.save
112
59
 
113
- note_id = note.id
60
+ # Finding the default deck
61
+ default_deck = anki21_database.find_deck_by(name: "Default")
62
+
63
+ # Finding all of the default Anki note types
64
+ basic_note_type = anki21_database.find_note_type_by(name: "Basic")
65
+ basic_and_reversed_card_note_type = anki21_database.find_note_type_by(name: "Basic (and reversed card)")
66
+ basic_and_optional_reversed_card_note_type = anki21_database.find_note_type_by(name: "Basic (optional reversed card)")
67
+ basic_type_in_the_answer_note_type = anki21_database.find_note_type_by(name: "Basic (type in the answer)")
68
+ cloze_note_type = anki21_database.find_note_type_by(name: "Cloze")
69
+
70
+ # Creating new notes using the default note types
71
+
72
+ basic_note = AnkiRecord::Note.new(note_type: basic_note_type, deck: default_deck)
73
+ basic_note.front = "What molecule is most relevant to the name aerobic exercise?"
74
+ basic_note.back = "Oxygen"
75
+ basic_note.save
76
+
77
+ # Creating a new nested deck
78
+ amino_acids_deck = AnkiRecord::Deck.new(anki21_database:,
79
+ name: "Biochemistry::Amino acids")
80
+ amino_acids_deck.save
81
+
82
+ basic_and_reversed_note = AnkiRecord::Note.new(note_type: basic_and_reversed_card_note_type,
83
+ deck: amino_acids_deck)
84
+ basic_and_reversed_note.front = "Tyrosine"
85
+ basic_and_reversed_note.back = "Y"
86
+ basic_and_reversed_note.save
87
+
88
+ basic_and_optional_reversed_note = AnkiRecord::Note.new(note_type: basic_and_optional_reversed_card_note_type,
89
+ deck: default_deck)
90
+ basic_and_optional_reversed_note.front = "A technique where locations along a route are memorized and associated with ideas"
91
+ basic_and_optional_reversed_note.back = "The method of loci"
92
+ basic_and_optional_reversed_note.add_reverse = "Have a reverse card too"
93
+ basic_and_optional_reversed_note.save
94
+
95
+ basic_type_in_the_answer_note = AnkiRecord::Note.new(note_type: basic_type_in_the_answer_note_type,
96
+ deck: default_deck)
97
+ basic_type_in_the_answer_note.front = "What Git command commits staged changes by changing the previous commit without editing the commit message?"
98
+ basic_type_in_the_answer_note.back = "git commit --amend --no-edit"
99
+ basic_type_in_the_answer_note.save
100
+
101
+ cloze_note = AnkiRecord::Note.new(note_type: cloze_note_type, deck: default_deck)
102
+ cloze_note.text = "Dysfunction of CN {{c1::VII}} occurs in Bell's palsy"
103
+ cloze_note.back_extra = "This condition involves one cranial nerve but can have myriad neurological symptoms"
104
+ cloze_note.save
114
105
  end
106
+ # An example.apkg file should be in the current
107
+ # working directory with 6 notes.
115
108
 
116
- AnkiRecord::AnkiPackage.open(path: "./test_1.apkg") do |collection|
117
- note = collection.find_note_by id: note_id
118
- note.crazy_back = "Ruby"
119
- note.save
120
- end
121
109
  ```
122
110
 
123
- This script creates an Anki package `test_1.apkg` with a new deck and new note type, and one note in that deck using that type. It then opens that Anki package, and edits the note. Note that the `test_1.apkg` file is not changed by this. Instead, a new package with a name similar to `test_1-1679835468.apkg` is created (the number is a timestamp).
111
+ `AnkiRecord::AnkiPackage.new` can also take a `target_directory` keyword argument to specify the directory to save the Anki package. If an error is thrown inside the block argument, temporary files that exist during execution of the block (Anki SQLite databases and the file called `media`) are deleted and no new Anki package is created.
124
112
 
125
- ## Documentation
113
+ The gem can also be used to update an existing Anki package:
126
114
 
127
- The [API Documentation](https://kylerego.github.io/anki_record_docs) is generated using SDoc from comments in the source code. You might notice that some public methods are intentionally omitted from this documentation. Although public, these methods are not intended to be used outside of the gem's implementation and should be treated as private.
115
+ ```ruby
116
+ require "anki_record"
128
117
 
129
- The RSpec examples are intended to provide executable documentation and may also be helpful to understand the API. Running the test suite with the `rspec` command will output these in a more readable way that also reflects the nesting of the RSpec examples and example groups. This is an example of part of the output:
118
+ AnkiRecord::AnkiPackage.update(path: "./example.apkg") do |anki21_database|
119
+ amino_acids_deck = anki21_database.find_deck_by(name: "Biochemistry::Amino acids")
120
+ custom_note_type = anki21_database.find_note_type_by(name: "New custom note type")
130
121
 
122
+ # Create more decks, note types, notes etc. There are not many methods that would be useful here for finding and updating notes yet.
123
+ end
131
124
  ```
132
- AnkiRecord::Deck#save
133
- when the deck does not exist in the collection.anki21 database
134
- saves the deck object's id as a key in the decks column's JSON object in the collection.anki21 database
135
- saves the deck object as a hash, as the value of the deck object's id key, in the decks hash
136
- saves the deck object as a hash with the following keys: 'id', 'mod', 'name', 'usn', 'lrnToday', 'revToday', 'newToday', 'timeToday', 'collapsed', 'browserCollapsed', 'desc', 'dyn', 'conf', 'extendNew', 'extendRev'
137
- saves the deck object as a hash with the deck object's id attribute as the value for the id key in the deck hash
138
- saves the deck object as a hash with the deck object's last_modified_timestamp attribute as the value for the mod key in the deck hash
139
125
 
140
- ```
126
+ If an error is thrown in the block here, the original Anki package will not be changed.
141
127
 
142
- The RSpec test suite files in `spec` are organized similarly to the the source code in `lib`.
128
+ ## Documentation
143
129
 
144
- <!-- ## Development
130
+ The [API Documentation](https://kylerego.github.io/anki_record_docs) generated from source code comments might be useful but I think the examples above show everything you can do that you would want to do.
131
+
132
+ ## Development
145
133
 
146
134
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
147
135
 
148
136
  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).
149
137
 
150
- ### Development road map:
151
- - Better messages when `ArgumentError` raised
152
- - Add #inspect methods
153
- - Refactor tests to improve speed
154
- - Copying the contents of an existing package into the new package when it is opened
155
- - Add more unit tests
156
- - Work on creating, updating, and saving notes and cards to the collection.anki21 database
157
- - Updating notes when they already exist in the database
158
- - Add more unit tests
159
- - Validation logic of what makes the note valid based on the note type's card templates and fields
160
- - Work on adding media support
161
- - The checksum calculation for notes will need to be updated to account for HTML in the content
162
- - Saving note types, decks, and deck options groups to the collection.anki21 database
163
- - Deck options groups cannot be saved yet.
164
- - Add being able to handle subdecks
165
- - Updating them when they already exist
166
- - Setters for any relevant attributes with validation
167
- - Refactoring
168
- - Use more specific RSpec matchers than `eq` everywhere
169
- - Investigate if note guid is determined in Anki in a non-random way
170
- - Investigate if the database ever needs to be explicitly opened or closed
171
- - Note type allowed fields: investigate if there are other special field names that should be allowed.
172
-
173
- ### Release checklist
174
- - Remove `require "pry"`
175
- - Update changelog
176
- - Update usage examples
177
- - Update and regenerate documentation
178
- - Bump version
179
- - Release gem -->
180
-
181
138
  <!-- ## Contributing
182
139
 
183
140
  Bug reports and pull requests are welcome on GitHub at https://github.com/KyleRego/anki_record. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/KyleRego/anki_record/blob/master/CODE_OF_CONDUCT.md). -->
data/anki_record.gemspec CHANGED
@@ -8,9 +8,9 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["Kyle Rego"]
9
9
  spec.email = ["regoky@outlook.com"]
10
10
 
11
- spec.summary = "Automate Anki flashcard editing with the Ruby programming language."
11
+ spec.summary = "Create and update Anki deck packages with Ruby."
12
12
  spec.description = <<-DESC
13
- A Ruby library which provides a programmatic interface to Anki flashcard decks (.apkg files/zipped Anki SQLite databases).
13
+ Anki Record lets you create Anki deck packages or update existing ones with the Ruby programming language.
14
14
  DESC
15
15
  spec.homepage = "https://github.com/KyleRego/anki_record"
16
16
  spec.license = "MIT"
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "anki21_database_attributes"
4
+ require_relative "anki21_database_constructors"
5
+
6
+ module AnkiRecord
7
+ ##
8
+ # Anki21Database represents the collection.anki21 Anki SQLite database in the Anki Package
9
+ class Anki21Database
10
+ include Anki21DatabaseAttributes
11
+ include Anki21DatabaseConstructors
12
+
13
+ def self.create_new(anki_package:) # :nodoc:
14
+ anki21_database = new
15
+ anki21_database.create_initialize(anki_package:)
16
+ anki21_database
17
+ end
18
+
19
+ def self.update_new(anki_package:) # :nodoc:
20
+ anki21_database = new
21
+ anki21_database.update_initialize(anki_package:)
22
+ anki21_database
23
+ end
24
+
25
+ ##
26
+ # Returns an SQLite3::Statement object (sqlite3 gem) to be executed against the collection.anki21 database.
27
+ #
28
+ # Statement#execute executes the statement.
29
+ def prepare(sql)
30
+ database.prepare sql
31
+ end
32
+
33
+ # rubocop:disable Metrics/MethodLength
34
+ ##
35
+ # Returns the note found by either +sfld" or +id+, or nil if it is not found.
36
+ def find_note_by(sfld: nil, id: nil)
37
+ if (id && sfld) || (id.nil? && sfld.nil?)
38
+ raise ArgumentError,
39
+ "You must pass either an id or sfld keyword argument to find_note_by."
40
+ end
41
+ note_cards_data = if id
42
+ note_cards_data_for_note(id:)
43
+ else
44
+ note_cards_data_for_note(sfld:)
45
+ end
46
+ return nil unless note_cards_data
47
+
48
+ AnkiRecord::Note.new(anki21_database: self, data: note_cards_data)
49
+ end
50
+ # rubocop:enable Metrics/MethodLength
51
+
52
+ ##
53
+ # Returns an array of notes that have any field value matching +text+
54
+ def find_notes_by_exact_text_match(text:)
55
+ return [] if text == ""
56
+
57
+ like_matcher = "%#{text}%"
58
+ note_datas = prepare("select * from notes where flds LIKE ?").execute([like_matcher])
59
+
60
+ note_datas.map do |note_data|
61
+ id = note_data["id"]
62
+
63
+ cards_data = prepare("select * from cards where nid = ?").execute([id]).to_a
64
+ note_cards_data = { note_data:, cards_data: }
65
+
66
+ AnkiRecord::Note.new(anki21_database: self, data: note_cards_data)
67
+ end
68
+ end
69
+
70
+ ##
71
+ # Returns the note type found by either +name+ or +id+, or nil if it is not found.
72
+ def find_note_type_by(name: nil, id: nil)
73
+ if (id && name) || (id.nil? && name.nil?)
74
+ raise ArgumentError,
75
+ "You must pass either an id or name keyword argument to find_note_type_by."
76
+ end
77
+
78
+ name ? find_note_type_by_name(name:) : find_note_type_by_id(id:)
79
+ end
80
+
81
+ ##
82
+ # Returns the deck found by either +name+ or +id+, or nil if it is not found.
83
+ def find_deck_by(name: nil, id: nil)
84
+ if (id && name) || (id.nil? && name.nil?)
85
+ raise ArgumentError,
86
+ "You must pass either an id or name keyword argument to find_deck_by."
87
+ end
88
+
89
+ name ? find_deck_by_name(name:) : find_deck_by_id(id:)
90
+ end
91
+
92
+ ##
93
+ # Returns the deck options group object found by +id+, or nil if it is not found.
94
+ def find_deck_options_group_by(id:)
95
+ deck_options_groups.find { |deck_options_group| deck_options_group.id == id }
96
+ end
97
+
98
+ def decks_json # :nodoc:
99
+ JSON.parse(prepare("select decks from col;").execute.first["decks"])
100
+ end
101
+
102
+ def models_json # :nodoc:
103
+ JSON.parse(prepare("select models from col;").execute.first["models"])
104
+ end
105
+
106
+ def col_record # :nodoc:
107
+ prepare("select * from col").execute.first
108
+ end
109
+
110
+ def add_note_type(note_type) # :nodoc:
111
+ raise ArgumentError unless note_type.instance_of?(AnkiRecord::NoteType)
112
+
113
+ existing_note_type = nil
114
+ note_types.each do |nt|
115
+ existing_note_type = nt if nt.id == note_type.id
116
+ end
117
+ note_types.delete(existing_note_type) if existing_note_type
118
+
119
+ note_types << note_type
120
+ end
121
+
122
+ def add_deck(deck) # :nodoc:
123
+ raise ArgumentError unless deck.instance_of?(AnkiRecord::Deck)
124
+
125
+ decks << deck
126
+ end
127
+
128
+ def add_deck_options_group(deck_options_group) # :nodoc:
129
+ raise ArgumentError unless deck_options_group.instance_of?(AnkiRecord::DeckOptionsGroup)
130
+
131
+ deck_options_groups << deck_options_group
132
+ end
133
+
134
+ # :nocov:
135
+ def inspect # :nodoc:
136
+ "[= Anki21Database of package with name #{package.name} =]"
137
+ end
138
+ # :nocov:
139
+
140
+ private
141
+
142
+ def find_note_type_by_name(name:)
143
+ note_types.find { |note_type| note_type.name == name }
144
+ end
145
+
146
+ def find_note_type_by_id(id:)
147
+ note_types.find { |note_type| note_type.id == id }
148
+ end
149
+
150
+ def find_deck_by_name(name:)
151
+ decks.find { |deck| deck.name == name }
152
+ end
153
+
154
+ def find_deck_by_id(id:)
155
+ decks.find { |deck| deck.id == id }
156
+ end
157
+
158
+ def note_cards_data_for_note(id: nil, sfld: nil)
159
+ note_data = if id
160
+ prepare("select * from notes where id = ?").execute([id]).first
161
+ elsif sfld
162
+ prepare("select * from notes where sfld = ?").execute([sfld]).first
163
+ end
164
+ return nil unless note_data
165
+
166
+ id ||= note_data["id"]
167
+
168
+ cards_data = prepare("select * from cards where nid = ?").execute([id]).to_a
169
+ { note_data:, cards_data: }
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnkiRecord
4
+ ##
5
+ # Module with Anki21Database's attribute readers, writers, and accessors
6
+ module Anki21DatabaseAttributes
7
+ ##
8
+ # The database's note types as an array
9
+ attr_reader :note_types
10
+
11
+ ##
12
+ # The database's decks as an array
13
+ attr_reader :decks
14
+
15
+ ##
16
+ # The database's deck option groups as an array
17
+ attr_reader :deck_options_groups
18
+
19
+ ##
20
+ # The database's parent Anki package
21
+ attr_reader :anki_package
22
+
23
+ ##
24
+ # The database's collection record
25
+ attr_reader :collection
26
+
27
+ ##
28
+ # The database's collection.anki21 SQLite3::Database
29
+ attr_reader :database
30
+ end
31
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnkiRecord
4
+ module Anki21DatabaseConstructors # :nodoc:
5
+ FILENAME = "collection.anki21"
6
+
7
+ def create_initialize(anki_package:)
8
+ @anki_package = anki_package
9
+ @database = SQLite3::Database.new "#{anki_package.tmpdir}/#{FILENAME}", options: {}
10
+ database.execute_batch ANKI_SCHEMA_DEFINITION
11
+ database.execute INSERT_COLLECTION_ANKI_21_COL_RECORD
12
+ database.results_as_hash = true
13
+ @collection = Collection.new(anki21_database: self)
14
+ initialize_note_types
15
+ initialize_deck_options_groups
16
+ initialize_decks
17
+ end
18
+
19
+ def update_initialize(anki_package:)
20
+ @anki_package = anki_package
21
+ @database = SQLite3::Database.new("#{anki_package.tmpdir}/#{FILENAME}", options: {})
22
+ database.results_as_hash = true
23
+ @collection = Collection.new(anki21_database: self)
24
+ initialize_note_types
25
+ initialize_deck_options_groups
26
+ initialize_decks
27
+ end
28
+
29
+ private
30
+
31
+ def initialize_note_types
32
+ @note_types = []
33
+ JSON.parse(col_record["models"]).values.map do |model_hash|
34
+ NoteType.new(anki21_database: self, args: model_hash)
35
+ end
36
+ end
37
+
38
+ def initialize_decks
39
+ @decks = []
40
+ JSON.parse(col_record["decks"]).values.map do |deck_hash|
41
+ Deck.new(anki21_database: self, args: deck_hash)
42
+ end
43
+ end
44
+
45
+ def initialize_deck_options_groups
46
+ @deck_options_groups = []
47
+ JSON.parse(col_record["dconf"]).values.map do |dconf_hash|
48
+ DeckOptionsGroup.new(anki21_database: self, args: dconf_hash)
49
+ end
50
+ end
51
+ end
52
+ end