good_audible_story_sync 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +113 -0
  4. data/bin/good-audible-story-sync +6 -0
  5. data/lib/good_audible_story_sync/audible/auth.rb +262 -0
  6. data/lib/good_audible_story_sync/audible/auth_flow.rb +70 -0
  7. data/lib/good_audible_story_sync/audible/client.rb +220 -0
  8. data/lib/good_audible_story_sync/audible/library.rb +318 -0
  9. data/lib/good_audible_story_sync/audible/library_item.rb +213 -0
  10. data/lib/good_audible_story_sync/audible/user_profile.rb +39 -0
  11. data/lib/good_audible_story_sync/audible.rb +13 -0
  12. data/lib/good_audible_story_sync/database/audible_books.rb +66 -0
  13. data/lib/good_audible_story_sync/database/client.rb +70 -0
  14. data/lib/good_audible_story_sync/database/credentials.rb +48 -0
  15. data/lib/good_audible_story_sync/database/goodreads_books.rb +60 -0
  16. data/lib/good_audible_story_sync/database/storygraph_books.rb +74 -0
  17. data/lib/good_audible_story_sync/database/sync_times.rb +52 -0
  18. data/lib/good_audible_story_sync/database.rb +16 -0
  19. data/lib/good_audible_story_sync/goodreads/auth.rb +137 -0
  20. data/lib/good_audible_story_sync/goodreads/auth_flow.rb +70 -0
  21. data/lib/good_audible_story_sync/goodreads/book.rb +171 -0
  22. data/lib/good_audible_story_sync/goodreads/client.rb +98 -0
  23. data/lib/good_audible_story_sync/goodreads/library.rb +149 -0
  24. data/lib/good_audible_story_sync/goodreads.rb +12 -0
  25. data/lib/good_audible_story_sync/input_loop.rb +214 -0
  26. data/lib/good_audible_story_sync/options.rb +70 -0
  27. data/lib/good_audible_story_sync/storygraph/auth.rb +91 -0
  28. data/lib/good_audible_story_sync/storygraph/auth_flow.rb +70 -0
  29. data/lib/good_audible_story_sync/storygraph/book.rb +261 -0
  30. data/lib/good_audible_story_sync/storygraph/client.rb +247 -0
  31. data/lib/good_audible_story_sync/storygraph/library.rb +183 -0
  32. data/lib/good_audible_story_sync/storygraph/look_up_book_flow.rb +172 -0
  33. data/lib/good_audible_story_sync/storygraph/mark_finished_flow.rb +201 -0
  34. data/lib/good_audible_story_sync/storygraph.rb +14 -0
  35. data/lib/good_audible_story_sync/util/cipher.rb +43 -0
  36. data/lib/good_audible_story_sync/util/keychain.rb +32 -0
  37. data/lib/good_audible_story_sync/util.rb +92 -0
  38. data/lib/good_audible_story_sync/version.rb +6 -0
  39. data/lib/good_audible_story_sync.rb +14 -0
  40. metadata +80 -0
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ module GoodAudibleStorySync
5
+ module Storygraph
6
+ class MarkFinishedFlow
7
+ extend T::Sig
8
+
9
+ class UserCommand < T::Enum
10
+ enums do
11
+ SetReadDate = new("r")
12
+ NextBook = new("n")
13
+ Cancel = new("c")
14
+ Quit = new("q")
15
+ end
16
+ end
17
+
18
+ sig do
19
+ params(
20
+ audible_library: Audible::Library,
21
+ library: Library,
22
+ client: Client,
23
+ db_client: Database::Client
24
+ ).void
25
+ end
26
+ def self.run(audible_library:, library:, client:, db_client:)
27
+ new(audible_library: audible_library, library: library, client: client,
28
+ db_client: db_client).run
29
+ end
30
+
31
+ sig do
32
+ params(
33
+ audible_library: Audible::Library,
34
+ library: Library,
35
+ client: Client,
36
+ db_client: Database::Client
37
+ ).void
38
+ end
39
+ def initialize(audible_library:, library:, client:, db_client:)
40
+ @audible_library = audible_library
41
+ @library = library
42
+ @client = client
43
+ @db_client = db_client
44
+ @current_book = T.let(nil, T.nilable(Book))
45
+ @current_finish_date = T.let(nil, T.nilable(Date))
46
+ @stop_marking_finished = T.let(false, T::Boolean)
47
+ end
48
+
49
+ sig { void }
50
+ def run
51
+ finish_dates_by_isbn.each do |isbn, finish_date|
52
+ @current_finish_date = finish_date
53
+ process_book(isbn)
54
+ break if @stop_marking_finished
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ sig { params(isbn: String).void }
61
+ def process_book(isbn)
62
+ @current_book = find_book_by_isbn(isbn)
63
+ return unless @current_book
64
+
65
+ storygraph_finish_date = @current_book.finished_on
66
+ title_and_author = @current_book.title_and_author(stylize: true)
67
+
68
+ if storygraph_finish_date.nil?
69
+ puts "#{Util::INFO_EMOJI} Storygraph book #{title_and_author} has no finish date"
70
+ prompt_user_about_current_book
71
+ elsif storygraph_finish_date == @current_finish_date
72
+ puts "#{Util::SUCCESS_EMOJI} Storygraph book #{title_and_author} already " \
73
+ "marked as finished on #{Util.pretty_date(@current_finish_date)}"
74
+ else
75
+ puts "#{Util::WARNING_EMOJI} #{title_and_author}"
76
+ puts "#{Util::TAB}Storygraph finish date: #{Util.pretty_date(storygraph_finish_date)}"
77
+ puts "#{Util::TAB}Versus Audible: #{Util.pretty_date(T.must(@current_finish_date))}"
78
+ prompt_user_about_current_book
79
+ end
80
+ end
81
+
82
+ sig { params(isbn: String).returns(T.nilable(Book)) }
83
+ def find_book_by_isbn(isbn)
84
+ audible_book = @audible_library.find_by_isbn(isbn)
85
+ if audible_book
86
+ puts "#{Util::INFO_EMOJI}Looking up #{audible_book.to_s(stylize: true)} on Storygraph..."
87
+ end
88
+
89
+ # Do we already have the book associated with the ISBN in the local database?
90
+ book = @library.find_by_isbn(isbn)
91
+
92
+ unless book
93
+ # If not, search for it on Storygraph using the ISBN, then by title and author
94
+ book = @client.find_by_isbn(isbn, fallback_query: audible_book&.search_query)
95
+
96
+ if book
97
+ # Associate the book with its ISBN in the local library database
98
+ @library.add_book(book)
99
+ book.save_to_database(@db_client.storygraph_books)
100
+ else
101
+ puts "#{Util::WARNING_EMOJI} Book with ISBN #{isbn} not found on Storygraph"
102
+ end
103
+ end
104
+
105
+ book
106
+ end
107
+
108
+ sig { returns UserCommand }
109
+ def get_user_command
110
+ cmd = T.let(nil, T.nilable(UserCommand))
111
+ while cmd.nil?
112
+ print "Choose an option: "
113
+ input = gets.chomp
114
+ cmd = UserCommand.try_deserialize(input)
115
+ puts "Invalid command" if cmd.nil?
116
+ end
117
+ cmd
118
+ end
119
+
120
+ sig { void }
121
+ def prompt_user_about_current_book
122
+ book = T.must(@current_book)
123
+ puts book.to_s(stylize: true)
124
+ print_options
125
+ cmd = get_user_command
126
+ process_command(cmd)
127
+ puts
128
+ end
129
+
130
+ sig { params(cmd: UserCommand).void }
131
+ def process_command(cmd)
132
+ case cmd
133
+ when UserCommand::Quit then quit
134
+ when UserCommand::NextBook then skip_current_book
135
+ when UserCommand::SetReadDate then set_read_date_on_current_book
136
+ when UserCommand::Cancel then cancel
137
+ else
138
+ T.absurd(cmd)
139
+ end
140
+ end
141
+
142
+ sig { void }
143
+ def cancel
144
+ @stop_marking_finished = true
145
+ end
146
+
147
+ sig { returns T::Boolean }
148
+ def set_read_date_on_current_book
149
+ book = T.must(@current_book)
150
+ book_id = book.id
151
+ unless book_id
152
+ puts "#{Util::WARNING_EMOJI} Book #{book.title_and_author(stylize: true)} has no " \
153
+ "Storygraph ID, cannot set read date"
154
+ return false
155
+ end
156
+
157
+ finish_date = T.must(@current_finish_date)
158
+ success = @client.set_read_date(book_id, finish_date)
159
+
160
+ if success
161
+ book.finished_on = finish_date
162
+ book.save_to_database(@db_client.storygraph_books)
163
+ puts "#{Util::TAB}#{Util::SUCCESS_EMOJI} Done! #{book.url(stylize: true)}"
164
+ end
165
+
166
+ success
167
+ end
168
+
169
+ sig { void }
170
+ def skip_current_book
171
+ puts "#{Util::TAB}#{Util::INFO_EMOJI} Skipping..."
172
+ @current_book = nil
173
+ @current_finish_date = nil
174
+ end
175
+
176
+ sig { void }
177
+ def quit
178
+ puts "Goodbye!"
179
+ exit 0
180
+ end
181
+
182
+ sig { returns T::Hash[String, Date] }
183
+ def finish_dates_by_isbn
184
+ @finish_dates_by_isbn ||= @audible_library.finish_dates_by_isbn
185
+ end
186
+
187
+ sig { void }
188
+ def print_options
189
+ print_option(UserCommand::SetReadDate, "set read date to #{Util.pretty_date(T.must(@current_finish_date))}")
190
+ print_option(UserCommand::NextBook, "next book")
191
+ print_option(UserCommand::Cancel, "cancel")
192
+ print_option(UserCommand::Quit, "quit")
193
+ end
194
+
195
+ sig { params(option: UserCommand, description: String).void }
196
+ def print_option(option, description)
197
+ Util.print_option(option.serialize, description)
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodAudibleStorySync
4
+ module Storygraph
5
+ end
6
+ end
7
+
8
+ require_relative "storygraph/auth"
9
+ require_relative "storygraph/auth_flow"
10
+ require_relative "storygraph/book"
11
+ require_relative "storygraph/client"
12
+ require_relative "storygraph/library"
13
+ require_relative "storygraph/look_up_book_flow"
14
+ require_relative "storygraph/mark_finished_flow"
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require "lockbox"
5
+ require_relative "./keychain"
6
+
7
+ module GoodAudibleStorySync
8
+ module Util
9
+ class Cipher
10
+ extend T::Sig
11
+
12
+ ENCRYPTION_KEY_NAME = "good_audible_story_sync_encryption_key"
13
+
14
+ sig { returns String }
15
+ def self.key
16
+ result = Keychain.load(name: ENCRYPTION_KEY_NAME)
17
+ if result.nil? || result.empty?
18
+ puts "#{INFO_EMOJI} No encryption key found in keychain. Generating a new one..."
19
+ result = Lockbox.generate_key
20
+ Keychain.save(name: ENCRYPTION_KEY_NAME, value: result)
21
+ else
22
+ puts "#{INFO_EMOJI} Using GoodAudibleStorySync encryption key from keychain"
23
+ end
24
+ result
25
+ end
26
+
27
+ sig { void }
28
+ def initialize
29
+ @lockbox = Lockbox.new(key: self.class.key)
30
+ end
31
+
32
+ sig { params(contents: String).returns(String) }
33
+ def encrypt(contents)
34
+ @lockbox.encrypt(contents)
35
+ end
36
+
37
+ sig { params(value: String).returns(String) }
38
+ def decrypt(value)
39
+ @lockbox.decrypt(value)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+ # encoding: utf-8
4
+
5
+ module GoodAudibleStorySync
6
+ module Util
7
+ class Keychain
8
+ extend T::Sig
9
+
10
+ LOCK_EMOJI = "🔐"
11
+
12
+ sig { params(name: String, value: String).void }
13
+ def self.save(name:, value:)
14
+ account_name = self.account_name
15
+ puts "#{LOCK_EMOJI} Saving '#{name}' to #{account_name}'s keychain..."
16
+ `security add-generic-password -s '#{name}' -a '#{account_name}' -w '#{value}'`
17
+ end
18
+
19
+ sig { params(name: String).returns(T.nilable(String)) }
20
+ def self.load(name:)
21
+ account_name = self.account_name
22
+ puts "#{LOCK_EMOJI} Looking for '#{name}' in #{account_name}'s keychain..."
23
+ `security find-generic-password -w -s '#{name}' -a '#{account_name}'`.strip
24
+ end
25
+
26
+ sig { returns String }
27
+ def self.account_name
28
+ `whoami`.strip
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+ # encoding: utf-8
4
+
5
+ require "rainbow"
6
+
7
+ module GoodAudibleStorySync
8
+ module Util
9
+ extend T::Sig
10
+
11
+ TAB = " "
12
+ INFO_EMOJI = "ℹ️"
13
+ ERROR_EMOJI = "❌"
14
+ SAVE_EMOJI = "💾"
15
+ SUCCESS_EMOJI = "🟢"
16
+ DONE_EMOJI = "✅"
17
+ NEWLINE_EMOJI = "⮑"
18
+ WARNING_EMOJI = "🟡"
19
+
20
+ sig { params(words: T::Array[String]).returns(String) }
21
+ def self.join_words(words)
22
+ case words.size
23
+ when 0 then ""
24
+ when 1 then T.must(words[0])
25
+ when 2 then words.join(" and ")
26
+ else
27
+ head = T.must(words[0...-1]).join(", ")
28
+ tail = T.must(words[-1])
29
+ "#{head}, and #{tail}"
30
+ end
31
+ end
32
+
33
+ sig { params(words_str: String).returns(T::Array[String]) }
34
+ def self.split_words(words_str)
35
+ words_str.split(/, | and/).map { |word| word.strip.sub(/^and /, "") }
36
+ end
37
+
38
+ sig { params(timestamp: T.any(DateTime, Time)).returns(String) }
39
+ def self.pretty_time(timestamp)
40
+ # e.g., "Fri November 29, 2024 at 2:47am"
41
+ timestamp.strftime("%a %B %-d, %Y at %-l:%M%P")
42
+ end
43
+
44
+ sig { params(date: T.any(DateTime, Time, Date)).returns(String) }
45
+ def self.pretty_date(date)
46
+ # e.g., "Fri November 29, 2024"
47
+ date.strftime("%a %B %-d, %Y")
48
+ end
49
+
50
+ sig { params(str: T.nilable(String)).returns(T.nilable(String)) }
51
+ def self.squish(str)
52
+ return unless str
53
+ str.gsub(/[[:space:]]+/, " ").strip
54
+ end
55
+
56
+ sig { params(str: String).returns(T::Boolean) }
57
+ def self.integer?(str)
58
+ str.to_i.to_s == str
59
+ end
60
+
61
+ sig { params(str: String).returns(T::Boolean) }
62
+ def self.isbn?(str)
63
+ idx = str =~ /^(?=(?:\D*\d){10}(?:(?:\D*\d){3})?$)[\d-]+$/
64
+ idx == 0
65
+ end
66
+
67
+ sig { params(option: String, description: String).void }
68
+ def self.print_option(option, description)
69
+ desc_words = description.split(" ")
70
+ word_to_highlight = desc_words.detect { |word| word.downcase.start_with?(option) }
71
+ highlighted_word_index = desc_words.index(word_to_highlight)
72
+ highlighted_option = Rainbow(option).green
73
+ highlighted_word = if word_to_highlight
74
+ head = word_to_highlight.slice(0)
75
+ tail = word_to_highlight.slice(1..)
76
+ highlighted_head = Rainbow(head).green
77
+ "#{highlighted_head}#{tail}"
78
+ end
79
+ highlighted_description = if highlighted_word_index
80
+ head = (desc_words.slice(0, highlighted_word_index) || []).join(" ")
81
+ tail = (desc_words.slice(highlighted_word_index + 1..) || []).join(" ")
82
+ [head, highlighted_word, tail].compact.join(" ").strip
83
+ else
84
+ description
85
+ end
86
+ puts "#{highlighted_option}) #{highlighted_description}"
87
+ end
88
+ end
89
+ end
90
+
91
+ require_relative "util/cipher"
92
+ require_relative "util/keychain"
@@ -0,0 +1,6 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module GoodAudibleStorySync
5
+ VERSION = "0.0.5"
6
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sorbet-runtime"
4
+
5
+ module GoodAudibleStorySync
6
+ end
7
+
8
+ require_relative "good_audible_story_sync/audible"
9
+ require_relative "good_audible_story_sync/database"
10
+ require_relative "good_audible_story_sync/goodreads"
11
+ require_relative "good_audible_story_sync/input_loop"
12
+ require_relative "good_audible_story_sync/options"
13
+ require_relative "good_audible_story_sync/storygraph"
14
+ require_relative "good_audible_story_sync/util"
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: good_audible_story_sync
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.5
5
+ platform: ruby
6
+ authors:
7
+ - Sarah Vessels
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Interactive script to mark books as finished as well as set the finish
13
+ date on Storygraph, based on your Audible activity.
14
+ email: cheshire137@gmail.com
15
+ executables:
16
+ - good-audible-story-sync
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - README.md
22
+ - bin/good-audible-story-sync
23
+ - lib/good_audible_story_sync.rb
24
+ - lib/good_audible_story_sync/audible.rb
25
+ - lib/good_audible_story_sync/audible/auth.rb
26
+ - lib/good_audible_story_sync/audible/auth_flow.rb
27
+ - lib/good_audible_story_sync/audible/client.rb
28
+ - lib/good_audible_story_sync/audible/library.rb
29
+ - lib/good_audible_story_sync/audible/library_item.rb
30
+ - lib/good_audible_story_sync/audible/user_profile.rb
31
+ - lib/good_audible_story_sync/database.rb
32
+ - lib/good_audible_story_sync/database/audible_books.rb
33
+ - lib/good_audible_story_sync/database/client.rb
34
+ - lib/good_audible_story_sync/database/credentials.rb
35
+ - lib/good_audible_story_sync/database/goodreads_books.rb
36
+ - lib/good_audible_story_sync/database/storygraph_books.rb
37
+ - lib/good_audible_story_sync/database/sync_times.rb
38
+ - lib/good_audible_story_sync/goodreads.rb
39
+ - lib/good_audible_story_sync/goodreads/auth.rb
40
+ - lib/good_audible_story_sync/goodreads/auth_flow.rb
41
+ - lib/good_audible_story_sync/goodreads/book.rb
42
+ - lib/good_audible_story_sync/goodreads/client.rb
43
+ - lib/good_audible_story_sync/goodreads/library.rb
44
+ - lib/good_audible_story_sync/input_loop.rb
45
+ - lib/good_audible_story_sync/options.rb
46
+ - lib/good_audible_story_sync/storygraph.rb
47
+ - lib/good_audible_story_sync/storygraph/auth.rb
48
+ - lib/good_audible_story_sync/storygraph/auth_flow.rb
49
+ - lib/good_audible_story_sync/storygraph/book.rb
50
+ - lib/good_audible_story_sync/storygraph/client.rb
51
+ - lib/good_audible_story_sync/storygraph/library.rb
52
+ - lib/good_audible_story_sync/storygraph/look_up_book_flow.rb
53
+ - lib/good_audible_story_sync/storygraph/mark_finished_flow.rb
54
+ - lib/good_audible_story_sync/util.rb
55
+ - lib/good_audible_story_sync/util/cipher.rb
56
+ - lib/good_audible_story_sync/util/keychain.rb
57
+ - lib/good_audible_story_sync/version.rb
58
+ homepage: https://github.com/cheshire137/good-audible-story-sync
59
+ licenses:
60
+ - MIT
61
+ metadata: {}
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 3.6.9
77
+ specification_version: 4
78
+ summary: Command-line tool to sync your read books from Audible to Storygraph and,
79
+ eventually, Goodreads.
80
+ test_files: []