anki_record 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/CHANGELOG.md +31 -9
  4. data/Gemfile +1 -1
  5. data/Gemfile.lock +4 -2
  6. data/README.md +114 -31
  7. data/anki_record.gemspec +1 -5
  8. data/lib/anki_record/anki_package/anki_package.rb +245 -0
  9. data/lib/anki_record/anki_package/database_setup_constants.rb +91 -0
  10. data/lib/anki_record/card/card.rb +108 -0
  11. data/lib/anki_record/card/card_attributes.rb +39 -0
  12. data/lib/anki_record/card_template/card_template.rb +64 -0
  13. data/lib/anki_record/card_template/card_template_attributes.rb +69 -0
  14. data/lib/anki_record/collection/collection.rb +180 -0
  15. data/lib/anki_record/collection/collection_attributes.rb +35 -0
  16. data/lib/anki_record/deck/deck.rb +101 -0
  17. data/lib/anki_record/deck/deck_attributes.rb +30 -0
  18. data/lib/anki_record/deck/deck_defaults.rb +19 -0
  19. data/lib/anki_record/{deck_options_group.rb → deck_options_group/deck_options_group.rb} +10 -29
  20. data/lib/anki_record/deck_options_group/deck_options_group_attributes.rb +23 -0
  21. data/lib/anki_record/helpers/checksum_helper.rb +2 -5
  22. data/lib/anki_record/helpers/data_query_helper.rb +13 -0
  23. data/lib/anki_record/helpers/shared_constants_helper.rb +1 -3
  24. data/lib/anki_record/helpers/time_helper.rb +7 -5
  25. data/lib/anki_record/note/note.rb +181 -0
  26. data/lib/anki_record/note/note_attributes.rb +56 -0
  27. data/lib/anki_record/note_field/note_field.rb +62 -0
  28. data/lib/anki_record/note_field/note_field_attributes.rb +39 -0
  29. data/lib/anki_record/note_field/note_field_defaults.rb +19 -0
  30. data/lib/anki_record/note_type/note_type.rb +161 -0
  31. data/lib/anki_record/note_type/note_type_attributes.rb +80 -0
  32. data/lib/anki_record/note_type/note_type_defaults.rb +38 -0
  33. data/lib/anki_record/version.rb +1 -1
  34. data/lib/anki_record.rb +1 -16
  35. metadata +26 -16
  36. data/lib/anki_record/anki_package.rb +0 -194
  37. data/lib/anki_record/card.rb +0 -75
  38. data/lib/anki_record/card_template.rb +0 -105
  39. data/lib/anki_record/collection.rb +0 -105
  40. data/lib/anki_record/db/anki_schema_definition.rb +0 -77
  41. data/lib/anki_record/db/clean_collection21_record.rb +0 -10
  42. data/lib/anki_record/db/clean_collection2_record.rb +0 -10
  43. data/lib/anki_record/deck.rb +0 -101
  44. data/lib/anki_record/note.rb +0 -135
  45. data/lib/anki_record/note_field.rb +0 -84
  46. data/lib/anki_record/note_type.rb +0 -233
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 060b1e641776c8bf67bc20fa50029c1801ca7b367e2e038b2aa9b3024cd7b22b
4
- data.tar.gz: 21a27f8b9d0726122bc2ba22eb6132573e161d30d73e4db866f081db87ea8409
3
+ metadata.gz: 4da2246d01a22eec129d18c242062ac1eb39b9b794129bf4666fc7eb2615087b
4
+ data.tar.gz: 5de4bdb4e80e078d8b5711466b2eba97b68563bfbb44f24805fc63b909a31d08
5
5
  SHA512:
6
- metadata.gz: 8fdf8b0330814f95a11dffba1fbf2d113aa97269a8eba51d3675d7a8a761e37148dc5561b5fd96eeb9c94fd8b416ae00ade70acf88b20a0e5f7a9a668c4a8a7b
7
- data.tar.gz: de6e49c1acbb6f56b952f682be94affaa8beecfcea79fb473fed9b53f0970e40394302b48a2b2cc167315589d8200669a13f5f4721ac184a4606b8a3ef4972f2
6
+ metadata.gz: ac4bb975b12ca50c5d9bba91caeec3accd75a952f04ae88a17ebef9b6b15cc7af335316ee024f1f50322ec77d321aad9106c6fac3a99eb657f48a1f513353c35
7
+ data.tar.gz: 62b7cbd1d173f23eb2c1a3b816a092f7cbd7200945ce8bea5aafa2d73dc59714f571f2869aadb62016749dbc2446d0f43cb16fc5d670f89ea8bc3507e2c2a2f0
data/.rubocop.yml CHANGED
@@ -17,7 +17,9 @@ Layout/LineLength:
17
17
 
18
18
  Metrics/BlockLength:
19
19
  Exclude:
20
+ - "spec/*"
20
21
  - "spec/anki_record/*"
22
+ - "spec/integration/*"
21
23
 
22
24
  Layout/IndentationConsistency:
23
25
  EnforcedStyle: indented_internal_methods
data/CHANGELOG.md CHANGED
@@ -1,16 +1,38 @@
1
1
  ## [Development started] - 02-02-2023
2
2
 
3
- ## [Unreleased/0.1.0] - 02-22-2023
4
-
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.
7
-
8
3
  ## [0.1.1] - 02-24-2023
9
4
 
10
- - Updated documentation to release the first version
5
+ - The initial version released.
6
+ - The gem can create empty `.apkg` files that import into Anki.
7
+ - SQL statements can be executed against the `collection.anki21` database before the zip file is created.
11
8
 
12
9
  ## [0.2.0] - 03-05-2023
13
10
 
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
11
+ - `AnkiPackage#zip_and_close` renamed to `AnkiPackage#zip`.
12
+ - Decks and note types can be accessed with `Collection#find_deck_by` and `Collection#find_note_type_by`.
13
+ - Note objects can be created and updated, and then saved to the `collection.anki21` database.
14
+ - This also populates corresponding records in the `cards` table.
15
+
16
+ ## [0.3.0] - 03-26-2023
17
+
18
+ - `AnkiPackage::new` yields the collection object to the block instead of the Anki package object.
19
+ - `AnkiPackage::open` has been developed to a point that it is useable.
20
+ - An "opened" Anki package now has its contents copied into the temporary `collection.anki21` database.
21
+ - `AnkiPackage#execute` was removed.
22
+ - `AnkiPackage#prepare` was added. Any SQL statements executed directly against `collection.anki21` must now be prepared statements.
23
+ - Custom decks (and nested decks/subdecks) and custom note types can be created and updated, and then saved to the `collection.anki21` database.
24
+ - Notes can be accessed with `Collection#find_note_by`.
25
+ - `Note#save` now updates a note (and its corresponding cards) if it was already in the `collection.anki21` database.
26
+ - `Note::new` does not accept a `cloze` argument anymore; this attribute can be changed after instantiation with the `cloze=` setter.
27
+ - `Deck` has a `deck_options_group` attribute instead of `deck_options_group_id`
28
+ - `#inspect` added to `Deck`
29
+ - Deck options groups can be accessed with `Collection#find_deck_options_group_by`
30
+ - Multiple classes with `last_modified_time` and `creation_timestamp` attributes had these renamed to `last_modified_timestamp` and `created_at_timestamp`.
31
+ - More helpful error messages in various places (e.g. "The package name must be a string without spaces.").
32
+ - Bug fixes that may have affected previous version:
33
+ - Instantiating a note type from an existing Anki package no longer duplicates the note type when it is saved.
34
+ - Note types are not instantiated/saved with an invalid `req` value.
35
+ - In fixing this bug, other issues with `tags` and `vers` were introduced and then fixed.
36
+ - It was also noticed that the default note types with "Basic" in the name should not have `tags` and `vers` so this was changed too.
37
+ - API documentation changed from using RDoc to SDoc with the Rails template.
38
+ - RSpec test suite was refactored to improve speed: 4 minutes -> 1.5 minutes.
data/Gemfile CHANGED
@@ -17,4 +17,4 @@ gem "rubocop", "~> 1.21"
17
17
 
18
18
  gem "pry"
19
19
 
20
- gem "rdoc"
20
+ gem "sdoc", "~> 2.6"
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- anki_record (0.2.0)
4
+ anki_record (0.3.0)
5
5
  rubyzip (>= 2.3)
6
6
  sqlite3 (~> 1.3)
7
7
 
@@ -55,6 +55,8 @@ GEM
55
55
  parser (>= 3.1.1.0)
56
56
  ruby-progressbar (1.11.0)
57
57
  rubyzip (2.3.2)
58
+ sdoc (2.6.1)
59
+ rdoc (>= 5.0)
58
60
  simplecov (0.22.0)
59
61
  docile (~> 1.1)
60
62
  simplecov-html (~> 0.11)
@@ -72,9 +74,9 @@ DEPENDENCIES
72
74
  anki_record!
73
75
  pry
74
76
  rake (~> 13.0)
75
- rdoc
76
77
  rspec (~> 3.0)
77
78
  rubocop (~> 1.21)
79
+ sdoc (~> 2.6)
78
80
  simplecov
79
81
 
80
82
  RUBY VERSION
data/README.md CHANGED
@@ -1,8 +1,10 @@
1
- # AnkiRecord
1
+ # Anki Record
2
2
 
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.**
3
+ Anki Record is a Ruby library which provides a programmatic interface to Anki flashcard decks/deck packages (`.apkg` files or Anki SQLite databases).
4
4
 
5
- [API Documentation](https://kylerego.github.io/anki_record_docs)
5
+ **Development is ongoing and currently released versions may have some bugs. The library does not support media yet.**
6
+
7
+ **To import the deck packages into Anki, click the File menu and then "Import" from inside Anki. Do not double click the `.apkg` file to open Anki and import it at the same time.**
6
8
 
7
9
  ## Installation
8
10
 
@@ -16,7 +18,7 @@ If bundler is not being used to manage dependencies, install the gem by executin
16
18
 
17
19
  ## Usage
18
20
 
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:
21
+ The Anki package object is instantiated with `AnkiRecord::AnkiPackage.new`. If this is passed a block, it will execute the block, and afterwards zip an `*.apkg` file where `*` is the name argument (this argument is not allowed to contain spaces):
20
22
 
21
23
  ```ruby
22
24
  require "anki_record"
@@ -27,9 +29,12 @@ AnkiRecord::AnkiPackage.new(name: "test") do |apkg|
27
29
  end
28
30
  puts "Countdown complete. Write any Ruby you want in here!"
29
31
  end
32
+ # test.apkg now exists in the current working directory.
30
33
  ```
31
34
 
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.
35
+ 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.
36
+
37
+ 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.
33
38
 
34
39
  Alternatively, if `AnkiRecord::Package::new` is not passed a block, the `zip` method must be explicitly called on the Anki package object:
35
40
 
@@ -37,39 +42,102 @@ Alternatively, if `AnkiRecord::Package::new` is not passed a block, the `zip` me
37
42
  require "anki_record"
38
43
 
39
44
  apkg = AnkiRecord::AnkiPackage.new(name: "test")
40
- apkg.zip
45
+ apkg.zip # This zips the temporary files into test.apkg, and then deletes them.
41
46
  ```
42
47
 
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:
48
+ The second, optional argument to `AnkiRecord::Package.new` is `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.
49
+
50
+ 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:
44
51
 
45
52
  ```ruby
46
53
  require "anki_record"
47
54
 
48
- apkg = AnkiRecord::AnkiPackage.new name: "test"
55
+ apkg = AnkiRecord::AnkiPackage.new(name: "test") do |apkg|
56
+ deck = apkg.collection.find_deck_by name: "Default"
57
+
58
+ note_type = apkg.collection.find_note_type_by name: "Basic"
59
+
60
+ note = AnkiRecord::Note.new note_type: note_type, deck: deck
61
+ note.front = "Hello"
62
+ note.back = "World"
63
+ note.save
49
64
 
50
- deck = apkg.collection.find_deck_by name: "Default"
65
+ note_type2 = apkg.collection.find_note_type_by name: "Cloze"
51
66
 
52
- note_type = apkg.collection.find_note_type_by name: "Basic"
67
+ note2 = AnkiRecord::Note.new note_type: note_type2, deck: deck
68
+ note2.text = "Cloze {{c1::Hello}}"
69
+ note2.back_extra = "World"
70
+ note2.save
71
+ end
53
72
 
54
- note = AnkiRecord::Note.new note_type: note_type, deck: deck
55
- note.front = "Hello"
56
- note.back = "World"
57
- note.save
73
+ ```
58
74
 
59
- note_type2 = apkg.collection.find_note_type_by name: "Cloze"
75
+ 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.
60
76
 
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
77
+ The next example shows some other features of the library:
78
+
79
+ ```ruby
80
+ require "anki_record"
65
81
 
66
- apkg.zip
82
+ note_id = nil
83
+
84
+ AnkiRecord::AnkiPackage.new(name: "test_1") do |collection|
85
+ crazy_deck = AnkiRecord::Deck.new collection: collection, name: "test_1_deck"
86
+
87
+ crazy_note_type = AnkiRecord::NoteType.new collection: collection, name: "test 1 note type"
88
+ AnkiRecord::NoteField.new note_type: crazy_note_type, name: "crazy front"
89
+ AnkiRecord::NoteField.new note_type: crazy_note_type, name: "crazy back"
90
+ crazy_card_template = AnkiRecord::CardTemplate.new note_type: crazy_note_type, name: "test 1 card 1"
91
+ crazy_card_template.question_format = "{{crazy front}}"
92
+ crazy_card_template.answer_format = "{{crazy back}}"
93
+ second_crazy_card_template = AnkiRecord::CardTemplate.new note_type: crazy_note_type, name: "test 1 card 2"
94
+ second_crazy_card_template.question_format = "{{crazy back}}"
95
+ second_crazy_card_template.answer_format = "{{crazy front}}"
96
+
97
+ css = <<~CSS
98
+ .card {
99
+ font-size: 16px;
100
+ font-style: Verdana;
101
+ background: transparent;
102
+ text-align: center;
103
+ }
104
+ CSS
105
+
106
+ crazy_note_type.css = css
107
+ crazy_note_type.save
108
+
109
+ note = AnkiRecord::Note.new note_type: crazy_note_type, deck: crazy_deck
110
+ note.crazy_front = "Hello from test 1"
111
+ note.crazy_back = "World"
112
+ note.save
113
+
114
+ note_id = note.id
115
+ end
67
116
 
117
+ AnkiRecord::AnkiPackage.open(path: "./test_1.apkg") do |collection|
118
+ note = collection.find_note_by id: note_id
119
+ note.crazy_back = "Ruby"
120
+ note.save
121
+ end
68
122
  ```
69
123
 
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.
124
+ 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).
125
+
126
+ ## Documentation
127
+
128
+ 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.
129
+
130
+ 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. The following is an example of output from one example in `spec/anki_record/note_spec.rb`:
131
+
132
+ ```
133
+ AnkiRecord::Note
134
+ #save
135
+ for a note that does not exist in the collection.anki21 database (custom note type, 2 card templates)
136
+ should save two card records to the collection.anki21 database
137
+ with nid values equal to the id of the cards' note object's id
138
+ ```
71
139
 
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)).
140
+ The RSpec test suite files in `spec` have a mapping with the source code in `lib`.
73
141
 
74
142
  ## Development
75
143
 
@@ -77,16 +145,31 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
77
145
 
78
146
  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).
79
147
 
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
148
+ ### Development road map:
149
+ - Better messages when `ArgumentError` raised
150
+ - Add #inspect methods
151
+ - Refactor tests to improve speed
152
+ - Copying the contents of an existing package into the new package when it is opened
153
+ - Add more unit tests
154
+ - Work on creating, updating, and saving notes and cards to the collection.anki21 database
155
+ - Updating notes when they already exist in the database
156
+ - Add more unit tests
157
+ - Validation logic of what makes the note valid based on the note type's card templates and fields
158
+ - Work on adding media support
159
+ - The checksum calculation for notes will need to be updated to account for HTML in the content
160
+ - Saving note types, decks, and deck options groups to the collection.anki21 database
161
+ - Deck options groups cannot be saved yet.
162
+ - Add being able to handle subdecks
163
+ - Updating them when they already exist
164
+ - Setters for any relevant attributes with validation
165
+ - Refactoring
166
+ - Use more specific RSpec matchers than `eq` everywhere
167
+ - Investigate if note guid is determined in Anki in a non-random way
168
+ - Investigate if the database ever needs to be explicitly opened or closed
169
+ - Note type allowed fields: investigate if there are other special field names that should be allowed.
88
170
 
89
171
  ### Release checklist
172
+ - Remove `require "pry"`
90
173
  - Update changelog
91
174
  - Update usage examples
92
175
  - Update and regenerate documentation
@@ -103,4 +186,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
103
186
 
104
187
  ## Code of Conduct
105
188
 
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).
189
+ Everyone interacting in the Anki Record 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
@@ -10,13 +10,12 @@ Gem::Specification.new do |spec|
10
10
 
11
11
  spec.summary = "Automate Anki flashcard editing with the Ruby programming language."
12
12
  spec.description = <<-DESC
13
- This Ruby library, which is currently in development, will provide an interface to inspect, update, and create Anki SQLite3 databases (*.apkg files).
13
+ A Ruby library which provides a programmatic interface to Anki flashcard decks (.apkg files/zipped Anki SQLite databases).
14
14
  DESC
15
15
  spec.homepage = "https://github.com/KyleRego/anki_record"
16
16
  spec.license = "MIT"
17
17
  spec.required_ruby_version = ">= 3.2.1"
18
18
 
19
- # spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'"
20
19
  spec.metadata["rubygems_mfa_required"] = "true"
21
20
  spec.metadata["homepage_uri"] = spec.homepage
22
21
  spec.metadata["source_code_uri"] = "https://github.com/KyleRego/anki_record"
@@ -35,7 +34,4 @@ Gem::Specification.new do |spec|
35
34
 
36
35
  spec.add_dependency "rubyzip", ">= 2.3"
37
36
  spec.add_dependency "sqlite3", "~> 1.3"
38
-
39
- # For more information and examples about making a new gem, check out our
40
- # guide at: https://bundler.io/guides/creating_gem.html
41
37
  end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require_relative "../card/card"
6
+ require_relative "../collection/collection"
7
+ require_relative "../note/note"
8
+ require_relative "database_setup_constants"
9
+
10
+ # rubocop:disable Metrics/ClassLength
11
+ module AnkiRecord
12
+ ##
13
+ # AnkiPackage represents an Anki package.
14
+ #
15
+ # Here, Anki package refers to the zip file that Anki can export and import.
16
+ class AnkiPackage
17
+ include AnkiRecord::DataQueryHelper
18
+
19
+ ##
20
+ # The package's collection object.
21
+ attr_reader :collection
22
+
23
+ ##
24
+ # Instantiates a new Anki package object.
25
+ #
26
+ # See the README for usage details.
27
+ def initialize(name:, target_directory: Dir.pwd, data: nil, open_path: nil, &closure)
28
+ check_name_argument_is_valid(name:)
29
+ @name = name.end_with?(".apkg") ? name[0, name.length - 5] : name
30
+ @target_directory = target_directory
31
+ @open_path = open_path
32
+ check_directory_argument_is_valid
33
+ setup_other_package_instance_variables
34
+ insert_existing_data(data: data) if data
35
+
36
+ execute_closure_and_zip(collection, &closure) if block_given?
37
+ end
38
+
39
+ # Returns an SQLite3::Statement object representing the given SQL and coupled to the collection.anki21 database.
40
+ #
41
+ # The Statement is executed using Statement#execute (see sqlite3 gem).
42
+ def prepare(sql)
43
+ @anki21_database.prepare sql
44
+ end
45
+
46
+ private
47
+
48
+ def execute_closure_and_zip(object_to_yield, &closure)
49
+ closure.call(object_to_yield)
50
+ rescue StandardError => e
51
+ destroy_temporary_directory
52
+ puts_error_and_standard_message(error: e)
53
+ else
54
+ zip
55
+ end
56
+
57
+ def setup_other_package_instance_variables
58
+ @tmpdir = Dir.mktmpdir
59
+ @tmp_files = []
60
+ @anki21_database = setup_anki21_database_object
61
+ @anki2_database = setup_anki2_database_object
62
+ @media_file = setup_media
63
+ @collection = Collection.new(anki_package: self)
64
+ end
65
+
66
+ def check_name_argument_is_valid(name:)
67
+ return if name.instance_of?(String) && !name.empty? && !name.include?(" ")
68
+
69
+ raise ArgumentError, "The package name must be a string without spaces."
70
+ end
71
+
72
+ def check_directory_argument_is_valid
73
+ raise ArgumentError, "No directory was found at the given path." unless File.directory?(@target_directory)
74
+ end
75
+
76
+ def setup_anki21_database_object
77
+ anki21_file_name = "collection.anki21"
78
+ db = SQLite3::Database.new "#{@tmpdir}/#{anki21_file_name}", options: {}
79
+ @tmp_files << anki21_file_name
80
+ db.execute_batch ANKI_SCHEMA_DEFINITION
81
+ db.execute INSERT_COLLECTION_ANKI_21_COL_RECORD
82
+ db.results_as_hash = true
83
+ db
84
+ end
85
+
86
+ def setup_anki2_database_object
87
+ anki2_file_name = "collection.anki2"
88
+ db = SQLite3::Database.new "#{@tmpdir}/#{anki2_file_name}", options: {}
89
+ @tmp_files << anki2_file_name
90
+ db.execute_batch ANKI_SCHEMA_DEFINITION
91
+ db.execute INSERT_COLLECTION_ANKI_2_COL_RECORD
92
+ db.close
93
+ db
94
+ end
95
+
96
+ def setup_media
97
+ media_file_path = FileUtils.touch("#{@tmpdir}/media")[0]
98
+ media_file = File.open(media_file_path, mode: "w")
99
+ media_file.write("{}")
100
+ media_file.close
101
+ @tmp_files << "media"
102
+ media_file
103
+ end
104
+
105
+ def insert_existing_data(data:)
106
+ @collection.copy_over_existing(col_record: data[:col_record])
107
+ copy_over_notes_and_cards(note_ids: data[:note_ids])
108
+ end
109
+
110
+ def copy_over_notes_and_cards(note_ids:)
111
+ temporarily_unzip_source_apkg do |source_collection_anki21|
112
+ note_ids.each do |note_id|
113
+ note_cards_data = note_cards_data_for_note_id(sql_able: source_collection_anki21, id: note_id)
114
+ AnkiRecord::Note.new(collection: @collection, data: note_cards_data).save
115
+ end
116
+ end
117
+ end
118
+
119
+ def standard_error_thrown_in_block_message
120
+ "Any temporary files created have been deleted.\nNo new *.apkg zip file was saved."
121
+ end
122
+
123
+ def puts_error_and_standard_message(error:)
124
+ puts error.backtrace
125
+ puts "#{error}\n#{standard_error_thrown_in_block_message}"
126
+ end
127
+
128
+ public
129
+
130
+ ##
131
+ # Instantiates a new Anki package object seeded with data from the opened Anki package.
132
+ #
133
+ # See the README for details.
134
+ def self.open(path:, target_directory: nil, &closure)
135
+ pathname = Pathname.new(path)
136
+ raise "*No .apkg file was found at the given path." unless pathname.file? && pathname.extname == ".apkg"
137
+
138
+ new_apkg_name = "#{File.basename(pathname.to_s, ".apkg")}-#{seconds_since_epoch}"
139
+ data = col_record_and_note_ids_to_copy_over(pathname: pathname)
140
+
141
+ if target_directory
142
+ new(name: new_apkg_name, data: data, open_path: pathname,
143
+ target_directory: target_directory, &closure)
144
+ else
145
+ new(name: new_apkg_name, data: data, open_path: pathname, &closure)
146
+ end
147
+ end
148
+
149
+ def was_instantiated_from_existing_apkg? # :nodoc:
150
+ !@open_path.nil?
151
+ end
152
+
153
+ # rubocop:disable Metrics/MethodLength
154
+
155
+ ##
156
+ # Unzips the *.apkg file that was opened and yields its collection.anki21 database
157
+ # as a SQLite3::Database object (see sqlite3 gem) to the block.
158
+ #
159
+ # After the block executes, the files created by unzipping are deleted.
160
+ #
161
+ # Throws an error if the Anki package was not instantiated using ::open.
162
+ #
163
+ def temporarily_unzip_source_apkg
164
+ raise ArgumentError unless @open_path && block_given?
165
+
166
+ Zip::File.open(@open_path) do |zip_file|
167
+ zip_file.each do |entry|
168
+ next unless entry.name == "collection.anki21"
169
+
170
+ entry.extract
171
+ source_collection_anki21 = SQLite3::Database.open "collection.anki21"
172
+ source_collection_anki21.results_as_hash = true
173
+
174
+ yield source_collection_anki21
175
+ end
176
+ end
177
+ File.delete("collection.anki21")
178
+ end
179
+ # rubocop:enable Metrics/MethodLength
180
+
181
+ class << self
182
+ include TimeHelper
183
+
184
+ # rubocop:disable Metrics/MethodLength
185
+ # rubocop:disable Metrics/AbcSize
186
+ def col_record_and_note_ids_to_copy_over(pathname:) # :nodoc:
187
+ data = {}
188
+ Zip::File.open(pathname) do |zip_file|
189
+ zip_file.each do |entry|
190
+ next unless entry.name == "collection.anki21"
191
+
192
+ entry.extract
193
+ source_collection_anki21 = SQLite3::Database.open "collection.anki21"
194
+ source_collection_anki21.results_as_hash = true
195
+ col_record = source_collection_anki21.prepare("select * from col").execute.first
196
+ note_ids = source_collection_anki21.prepare("select id from notes").execute.map { |note| note["id"] }
197
+ data = { col_record: col_record, note_ids: note_ids }
198
+ end
199
+ end
200
+ File.delete("collection.anki21")
201
+ data
202
+ end
203
+ # rubocop:enable Metrics/AbcSize
204
+ # rubocop:enable Metrics/MethodLength
205
+ end
206
+
207
+ ##
208
+ # Zips the temporary files (collection.anki21, collection.anki2, and media) into a new *.apkg package file.
209
+ #
210
+ # The temporary files, and the temporary directory they were in, are deleted after zipping.
211
+ def zip
212
+ create_zip_file && destroy_temporary_directory
213
+ end
214
+
215
+ private
216
+
217
+ def create_zip_file
218
+ Zip::File.open(target_zip_file, create: true) do |zip_file|
219
+ @tmp_files.each do |file_name|
220
+ zip_file.add(file_name, File.join(@tmpdir, file_name))
221
+ end
222
+ end
223
+ true
224
+ end
225
+
226
+ def target_zip_file
227
+ "#{@target_directory}/#{@name}.apkg"
228
+ end
229
+
230
+ def destroy_temporary_directory
231
+ FileUtils.rm_rf(@tmpdir)
232
+ end
233
+
234
+ public
235
+
236
+ def open? # :nodoc:
237
+ !closed?
238
+ end
239
+
240
+ def closed? # :nodoc:
241
+ @anki21_database.closed?
242
+ end
243
+ end
244
+ end
245
+ # rubocop:enable Metrics/ClassLength