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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +113 -0
- data/bin/good-audible-story-sync +6 -0
- data/lib/good_audible_story_sync/audible/auth.rb +262 -0
- data/lib/good_audible_story_sync/audible/auth_flow.rb +70 -0
- data/lib/good_audible_story_sync/audible/client.rb +220 -0
- data/lib/good_audible_story_sync/audible/library.rb +318 -0
- data/lib/good_audible_story_sync/audible/library_item.rb +213 -0
- data/lib/good_audible_story_sync/audible/user_profile.rb +39 -0
- data/lib/good_audible_story_sync/audible.rb +13 -0
- data/lib/good_audible_story_sync/database/audible_books.rb +66 -0
- data/lib/good_audible_story_sync/database/client.rb +70 -0
- data/lib/good_audible_story_sync/database/credentials.rb +48 -0
- data/lib/good_audible_story_sync/database/goodreads_books.rb +60 -0
- data/lib/good_audible_story_sync/database/storygraph_books.rb +74 -0
- data/lib/good_audible_story_sync/database/sync_times.rb +52 -0
- data/lib/good_audible_story_sync/database.rb +16 -0
- data/lib/good_audible_story_sync/goodreads/auth.rb +137 -0
- data/lib/good_audible_story_sync/goodreads/auth_flow.rb +70 -0
- data/lib/good_audible_story_sync/goodreads/book.rb +171 -0
- data/lib/good_audible_story_sync/goodreads/client.rb +98 -0
- data/lib/good_audible_story_sync/goodreads/library.rb +149 -0
- data/lib/good_audible_story_sync/goodreads.rb +12 -0
- data/lib/good_audible_story_sync/input_loop.rb +214 -0
- data/lib/good_audible_story_sync/options.rb +70 -0
- data/lib/good_audible_story_sync/storygraph/auth.rb +91 -0
- data/lib/good_audible_story_sync/storygraph/auth_flow.rb +70 -0
- data/lib/good_audible_story_sync/storygraph/book.rb +261 -0
- data/lib/good_audible_story_sync/storygraph/client.rb +247 -0
- data/lib/good_audible_story_sync/storygraph/library.rb +183 -0
- data/lib/good_audible_story_sync/storygraph/look_up_book_flow.rb +172 -0
- data/lib/good_audible_story_sync/storygraph/mark_finished_flow.rb +201 -0
- data/lib/good_audible_story_sync/storygraph.rb +14 -0
- data/lib/good_audible_story_sync/util/cipher.rb +43 -0
- data/lib/good_audible_story_sync/util/keychain.rb +32 -0
- data/lib/good_audible_story_sync/util.rb +92 -0
- data/lib/good_audible_story_sync/version.rb +6 -0
- data/lib/good_audible_story_sync.rb +14 -0
- metadata +80 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
require "rainbow"
|
|
5
|
+
|
|
6
|
+
module GoodAudibleStorySync
|
|
7
|
+
class InputLoop
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
class UserCommand < T::Enum
|
|
11
|
+
enums do
|
|
12
|
+
DisplayAudibleLibrary = new("a")
|
|
13
|
+
DisplayAudibleUserProfile = new("p")
|
|
14
|
+
DisplayGoodreadsLibrary = new("g")
|
|
15
|
+
DisplayStorygraphLibrary = new("s")
|
|
16
|
+
UpdateStorygraphLibraryCache = new("c")
|
|
17
|
+
MarkFinishedBooks = new("f")
|
|
18
|
+
LookUpStorygraphBook = new("l")
|
|
19
|
+
Quit = new("q")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { params(script_name: String).void }
|
|
24
|
+
def self.run(script_name:)
|
|
25
|
+
new(script_name: script_name).run
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { params(script_name: String).void }
|
|
29
|
+
def initialize(script_name:)
|
|
30
|
+
@script_name = script_name
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
sig { void }
|
|
34
|
+
def run
|
|
35
|
+
options # parse command line options
|
|
36
|
+
db_client # set up database tables
|
|
37
|
+
|
|
38
|
+
loop do
|
|
39
|
+
print_options
|
|
40
|
+
cmd = get_user_command
|
|
41
|
+
process_command(cmd)
|
|
42
|
+
puts
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
sig { void }
|
|
49
|
+
def print_options
|
|
50
|
+
print_option(UserCommand::DisplayAudibleLibrary, "display Audible library")
|
|
51
|
+
print_option(UserCommand::DisplayAudibleUserProfile, "display Audible user profile")
|
|
52
|
+
print_option(UserCommand::DisplayGoodreadsLibrary, "display Goodreads library")
|
|
53
|
+
print_option(UserCommand::DisplayStorygraphLibrary, "display Storygraph library")
|
|
54
|
+
print_option(UserCommand::LookUpStorygraphBook, "look up book on Storygraph")
|
|
55
|
+
print_option(UserCommand::UpdateStorygraphLibraryCache, "update Storygraph library cache")
|
|
56
|
+
print_option(UserCommand::MarkFinishedBooks, "mark finished books on Storygraph")
|
|
57
|
+
print_option(UserCommand::Quit, "quit")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
sig { params(option: UserCommand, description: String).void }
|
|
61
|
+
def print_option(option, description)
|
|
62
|
+
Util.print_option(option.serialize, description)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig { returns UserCommand }
|
|
66
|
+
def get_user_command
|
|
67
|
+
cmd = T.let(nil, T.nilable(UserCommand))
|
|
68
|
+
while cmd.nil?
|
|
69
|
+
print "Choose an option: "
|
|
70
|
+
input = gets.chomp
|
|
71
|
+
cmd = UserCommand.try_deserialize(input)
|
|
72
|
+
puts "Invalid command" if cmd.nil?
|
|
73
|
+
end
|
|
74
|
+
cmd
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
sig { params(cmd: UserCommand).void }
|
|
78
|
+
def process_command(cmd)
|
|
79
|
+
case cmd
|
|
80
|
+
when UserCommand::DisplayAudibleLibrary then display_audible_library
|
|
81
|
+
when UserCommand::DisplayAudibleUserProfile then display_audible_user_profile
|
|
82
|
+
when UserCommand::DisplayGoodreadsLibrary then display_goodreads_library
|
|
83
|
+
when UserCommand::DisplayStorygraphLibrary then display_storygraph_library
|
|
84
|
+
when UserCommand::UpdateStorygraphLibraryCache then update_storygraph_library_cache
|
|
85
|
+
when UserCommand::MarkFinishedBooks then mark_finished_books
|
|
86
|
+
when UserCommand::Quit then quit
|
|
87
|
+
when UserCommand::LookUpStorygraphBook then look_up_storygraph_book
|
|
88
|
+
else
|
|
89
|
+
T.absurd(cmd)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
sig { void }
|
|
94
|
+
def look_up_storygraph_book
|
|
95
|
+
Storygraph::LookUpBookFlow.run(client: storygraph_client, books_db: db_client.storygraph_books)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
sig { void }
|
|
99
|
+
def display_audible_library
|
|
100
|
+
puts audible_library.to_s(stylize: true)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
sig { returns Audible::Library }
|
|
104
|
+
def audible_library
|
|
105
|
+
@audible_library ||= Audible::Library.load_with_finish_times(client: audible_client,
|
|
106
|
+
options: options, db_client: db_client)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
sig { void }
|
|
110
|
+
def display_goodreads_library
|
|
111
|
+
puts goodreads_library.to_s(stylize: true)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
sig { void }
|
|
115
|
+
def display_storygraph_library
|
|
116
|
+
puts storygraph_library.to_s(stylize: true)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
sig { void }
|
|
120
|
+
def update_storygraph_library_cache
|
|
121
|
+
@storygraph_library = Storygraph::Library.load_from_web(client: storygraph_client,
|
|
122
|
+
db_client: db_client)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
sig { returns Goodreads::Library }
|
|
126
|
+
def goodreads_library
|
|
127
|
+
@goodreads_library ||= Goodreads::Library.load(client: goodreads_client, db_client: db_client, options: options)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
sig { returns Storygraph::Library }
|
|
131
|
+
def storygraph_library
|
|
132
|
+
@storygraph_library ||= Storygraph::Library.load(client: storygraph_client,
|
|
133
|
+
db_client: db_client, options: options)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
sig { void }
|
|
137
|
+
def display_audible_user_profile
|
|
138
|
+
puts "#{Util::INFO_EMOJI} Getting Audible user profile..."
|
|
139
|
+
user_profile = audible_client.get_user_profile
|
|
140
|
+
puts user_profile.to_s(indent_level: 1)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
sig { void }
|
|
144
|
+
def mark_finished_books
|
|
145
|
+
Storygraph::MarkFinishedFlow.run(
|
|
146
|
+
audible_library: audible_library,
|
|
147
|
+
library: storygraph_library,
|
|
148
|
+
client: storygraph_client,
|
|
149
|
+
db_client: db_client,
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
sig { void }
|
|
154
|
+
def quit
|
|
155
|
+
puts "Goodbye!"
|
|
156
|
+
exit 0
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
sig { returns Audible::Client }
|
|
160
|
+
def audible_client
|
|
161
|
+
@audible_client ||= Audible::Client.new(auth: audible_auth, options: options,
|
|
162
|
+
credentials_db: db_client.credentials)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
sig { returns Audible::Auth }
|
|
166
|
+
def audible_auth
|
|
167
|
+
return @audible_auth if @audible_auth
|
|
168
|
+
maybe_auth = GoodAudibleStorySync::Audible::AuthFlow.run(db_client: db_client)
|
|
169
|
+
exit 1 if maybe_auth.nil?
|
|
170
|
+
@audible_auth = maybe_auth
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
sig { returns Goodreads::Client }
|
|
174
|
+
def goodreads_client
|
|
175
|
+
@goodreads_client ||= Goodreads::Client.new(auth: goodreads_auth)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
sig { returns Storygraph::Client }
|
|
179
|
+
def storygraph_client
|
|
180
|
+
@storygraph_client ||= Storygraph::Client.new(auth: storygraph_auth)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
sig { returns Storygraph::Auth }
|
|
184
|
+
def storygraph_auth
|
|
185
|
+
return @storygraph_auth if @storygraph_auth
|
|
186
|
+
maybe_auth = Storygraph::AuthFlow.run(credentials_db: db_client.credentials)
|
|
187
|
+
exit 1 if maybe_auth.nil?
|
|
188
|
+
@storygraph_auth = maybe_auth
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
sig { returns Goodreads::Auth }
|
|
192
|
+
def goodreads_auth
|
|
193
|
+
return @goodreads_auth if @goodreads_auth
|
|
194
|
+
maybe_auth = Goodreads::AuthFlow.run(credentials_db: db_client.credentials)
|
|
195
|
+
exit 1 if maybe_auth.nil?
|
|
196
|
+
@goodreads_auth = maybe_auth
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
sig { returns Database::Client }
|
|
200
|
+
def db_client
|
|
201
|
+
@db_client ||= Database::Client.load(options.database_file, cipher: cipher)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
sig { returns Options }
|
|
205
|
+
def options
|
|
206
|
+
@options ||= Options.parse(script_name: @script_name, cipher: cipher)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
sig { returns Util::Cipher }
|
|
210
|
+
def cipher
|
|
211
|
+
@cipher ||= Util::Cipher.new
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
# encoding: utf-8
|
|
4
|
+
|
|
5
|
+
require "optparse"
|
|
6
|
+
|
|
7
|
+
module GoodAudibleStorySync
|
|
8
|
+
class Options
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
EMOJI_PREFIX = "⚙️"
|
|
12
|
+
DEFAULT_EXPIRATION_DAYS = 1
|
|
13
|
+
|
|
14
|
+
# sig { returns Options }
|
|
15
|
+
def self.default
|
|
16
|
+
parse(script_name: File.basename($0))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# sig { params(script_name: String, cipher: T.nilable(Util::Cipher), argv: Array).returns(Options) }
|
|
20
|
+
def self.parse(script_name:, cipher: nil, argv: ARGV)
|
|
21
|
+
puts "#{EMOJI_PREFIX} Parsing options..."
|
|
22
|
+
options = new(script_name: script_name, cipher: cipher, argv: argv)
|
|
23
|
+
options.parse
|
|
24
|
+
options
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# sig { params(script_name: String, cipher: T.nilable(Util::Cipher), argv: Array).void }
|
|
28
|
+
def initialize(script_name:, cipher: nil, argv: ARGV)
|
|
29
|
+
@options = {}
|
|
30
|
+
@argv = argv
|
|
31
|
+
@cipher = cipher || Util::Cipher.new
|
|
32
|
+
@option_parser = OptionParser.new do |opts|
|
|
33
|
+
opts.banner = "Usage: #{script_name} [options]"
|
|
34
|
+
opts.on(
|
|
35
|
+
"-d DATABASE_FILE",
|
|
36
|
+
"--database-file",
|
|
37
|
+
String,
|
|
38
|
+
"Path to Sqlite database file. Defaults to #{Database::Client::DEFAULT_DATABASE_FILE}.",
|
|
39
|
+
)
|
|
40
|
+
opts.on(
|
|
41
|
+
"-e EXPIRATION_DAYS",
|
|
42
|
+
"--expiration-days",
|
|
43
|
+
Integer,
|
|
44
|
+
"Max number of days to use cached data, such as Audible library, before " \
|
|
45
|
+
"refreshing. Defaults to #{DEFAULT_EXPIRATION_DAYS}.",
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# sig { void }
|
|
50
|
+
def parse
|
|
51
|
+
@option_parser.parse!(@argv, into: @options)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# sig { returns String }
|
|
55
|
+
def database_file
|
|
56
|
+
@database_file ||= @options[:"database-file"] || Database::Client::DEFAULT_DATABASE_FILE
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# sig { returns Integer }
|
|
60
|
+
def expiration_days
|
|
61
|
+
@expiration_days ||= @options[:"expiration-days"] || DEFAULT_EXPIRATION_DAYS
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# sig { returns Time }
|
|
65
|
+
def refresh_cutoff_time
|
|
66
|
+
Time.now - (expiration_days * 86400)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
require "mechanize"
|
|
5
|
+
|
|
6
|
+
module GoodAudibleStorySync
|
|
7
|
+
module Storygraph
|
|
8
|
+
class Auth
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
BASE_URL = "https://app.thestorygraph.com"
|
|
12
|
+
CREDENTIALS_DB_KEY = "storygraph"
|
|
13
|
+
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
sig { params(email: String, password: String).returns(Auth) }
|
|
17
|
+
def self.sign_in(email:, password:)
|
|
18
|
+
auth = new(data: { "email" => email, "password" => password })
|
|
19
|
+
auth.sign_in
|
|
20
|
+
auth
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { params(page: Mechanize::Page).returns(T::Boolean) }
|
|
24
|
+
def self.sign_in_page?(page)
|
|
25
|
+
page.uri.to_s.end_with?("/users/sign_in")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { returns T.nilable(String) }
|
|
29
|
+
attr_reader :email, :username
|
|
30
|
+
|
|
31
|
+
sig { returns Mechanize }
|
|
32
|
+
attr_reader :agent
|
|
33
|
+
|
|
34
|
+
sig { params(agent: T.nilable(Mechanize), data: T::Hash[String, T.nilable(String)]).void }
|
|
35
|
+
def initialize(agent: nil, data: {})
|
|
36
|
+
@agent = agent || Mechanize.new
|
|
37
|
+
@email = data["email"]
|
|
38
|
+
@password = data["password"]
|
|
39
|
+
@username = data["username"]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { void }
|
|
43
|
+
def sign_in
|
|
44
|
+
raise Error.new("Cannot sign in without credentials") if @email.nil? || @password.nil?
|
|
45
|
+
|
|
46
|
+
puts "#{Util::INFO_EMOJI} Signing into Storygraph as #{@email}..."
|
|
47
|
+
page = begin
|
|
48
|
+
@agent.get("#{BASE_URL}/users/sign_in")
|
|
49
|
+
rescue Errno::ETIMEDOUT => err
|
|
50
|
+
raise Error.new("Failed to load sign-in page: #{err}")
|
|
51
|
+
end
|
|
52
|
+
sign_in_form = page.form_with(action: "/users/sign_in") do |form|
|
|
53
|
+
form["user[email]"] = @email
|
|
54
|
+
form["user[password]"] = @password
|
|
55
|
+
end
|
|
56
|
+
page_after_sign_in = sign_in_form.submit
|
|
57
|
+
profile_link = page_after_sign_in.link_with(text: "Profile")
|
|
58
|
+
successful_sign_in = !profile_link.nil? && !self.class.sign_in_page?(page_after_sign_in)
|
|
59
|
+
raise Error.new("Invalid credentials") unless successful_sign_in
|
|
60
|
+
|
|
61
|
+
@username = profile_link.href.split("/profile/").last
|
|
62
|
+
puts "#{Util::SUCCESS_EMOJI} Successfully signed in to Storygraph as #{username}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig { returns T::Hash[String, T.untyped] }
|
|
66
|
+
def to_h
|
|
67
|
+
{ "email" => @email, "password" => @password, "username" => @username }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
sig { params(cred_client: Database::Credentials).void }
|
|
71
|
+
def save_to_database(cred_client)
|
|
72
|
+
cred_client.upsert(key: CREDENTIALS_DB_KEY, value: to_h)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
sig { params(cred_client: Database::Credentials).returns(T::Boolean) }
|
|
76
|
+
def load_from_database(cred_client)
|
|
77
|
+
storygraph_data = cred_client.find(key: CREDENTIALS_DB_KEY)
|
|
78
|
+
unless storygraph_data
|
|
79
|
+
puts "#{Util::INFO_EMOJI} No Storygraph credentials found in database"
|
|
80
|
+
return false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@email = storygraph_data["email"]
|
|
84
|
+
@password = storygraph_data["password"]
|
|
85
|
+
@username = storygraph_data["username"]
|
|
86
|
+
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
require "io/console"
|
|
5
|
+
|
|
6
|
+
module GoodAudibleStorySync
|
|
7
|
+
module Storygraph
|
|
8
|
+
class AuthFlow
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { params(credentials_db: Database::Credentials).returns(T.nilable(Auth)) }
|
|
12
|
+
def self.run(credentials_db:)
|
|
13
|
+
new(credentials_db: credentials_db).run
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
sig { params(credentials_db: Database::Credentials).void }
|
|
17
|
+
def initialize(credentials_db:)
|
|
18
|
+
@credentials_db = credentials_db
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
sig { returns T.nilable(Auth) }
|
|
22
|
+
def run
|
|
23
|
+
load_from_database || log_in_via_website
|
|
24
|
+
rescue Auth::Error
|
|
25
|
+
puts "Failed to sign in to Storygraph."
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
sig { returns T.nilable(Auth) }
|
|
32
|
+
def load_from_database
|
|
33
|
+
auth = Auth.new
|
|
34
|
+
puts "#{Util::INFO_EMOJI} Looking for saved Storygraph credentials in database..."
|
|
35
|
+
success = auth.load_from_database(@credentials_db)
|
|
36
|
+
puts "#{Util::SUCCESS_EMOJI} Found saved Storygraph credentials." if success
|
|
37
|
+
return nil unless success
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
auth.sign_in
|
|
41
|
+
rescue Auth::Error => err
|
|
42
|
+
puts "#{Util::ERROR_EMOJI} Failed to sign in to Storygraph: #{err.message}"
|
|
43
|
+
return nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
auth
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
sig { returns T.nilable(Auth) }
|
|
50
|
+
def log_in_via_website
|
|
51
|
+
puts "#{Util::INFO_EMOJI} Logging in to Storygraph..."
|
|
52
|
+
print "Enter Storygraph email: "
|
|
53
|
+
email = gets.chomp
|
|
54
|
+
print "Enter Storygraph password: "
|
|
55
|
+
password = T.unsafe(STDIN).noecho(&:gets).chomp
|
|
56
|
+
print "\n"
|
|
57
|
+
|
|
58
|
+
auth = begin
|
|
59
|
+
Auth.sign_in(email: email, password: password)
|
|
60
|
+
rescue Auth::Error => err
|
|
61
|
+
puts "#{Util::ERROR_EMOJI} Failed to sign in to Storygraph: #{err.message}"
|
|
62
|
+
return nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
auth.save_to_database(@credentials_db)
|
|
66
|
+
auth
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
require "date"
|
|
5
|
+
require "rainbow"
|
|
6
|
+
|
|
7
|
+
module GoodAudibleStorySync
|
|
8
|
+
module Storygraph
|
|
9
|
+
class Book
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
class Status < T::Enum
|
|
13
|
+
enums do
|
|
14
|
+
CurrentlyReading = new("currently reading")
|
|
15
|
+
DidNotFinish = new("did not finish")
|
|
16
|
+
ToRead = new("to read")
|
|
17
|
+
Read = new("read")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Public: From .book-pane element on a URL like
|
|
22
|
+
# https://app.thestorygraph.com/books-read/cheshire137.
|
|
23
|
+
sig do
|
|
24
|
+
params(
|
|
25
|
+
book_pane: Nokogiri::XML::Element,
|
|
26
|
+
page: Mechanize::Page,
|
|
27
|
+
extra_data: T::Hash[String, T.untyped]
|
|
28
|
+
).returns(Book)
|
|
29
|
+
end
|
|
30
|
+
def self.from_read_book(book_pane, page:, extra_data: {})
|
|
31
|
+
title_link = book_pane.at(".book-title-author-and-series h3 a")
|
|
32
|
+
raise "No title link found on #{page.uri}" unless title_link
|
|
33
|
+
|
|
34
|
+
author_el = book_pane.search(".book-title-author-and-series p").last
|
|
35
|
+
read_status_label = book_pane.at(".read-status-label")
|
|
36
|
+
|
|
37
|
+
new({
|
|
38
|
+
"title" => Util.squish(title_link.text.strip),
|
|
39
|
+
"author" => Util.squish(author_el&.text&.strip),
|
|
40
|
+
"url" => "#{page.uri.origin}#{title_link["href"]}",
|
|
41
|
+
"id" => book_pane["data-book-id"],
|
|
42
|
+
"finished_on" => extract_finish_date(book_pane),
|
|
43
|
+
"status" => read_status_label&.text&.strip&.downcase || Status::Read.serialize,
|
|
44
|
+
}.merge(extra_data))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Public: From a page like
|
|
48
|
+
# https://app.thestorygraph.com/books/96b3360a-289c-4e1c-b463-f9f0f5b72a0e.
|
|
49
|
+
sig { params(page: Mechanize::Page, extra_data: T::Hash[String, T.untyped]).returns(Book) }
|
|
50
|
+
def self.from_book_page(page, extra_data: {})
|
|
51
|
+
title_header = page.at(".book-title-author-and-series h3")
|
|
52
|
+
raise "No title header found on page #{page.uri}" unless title_header
|
|
53
|
+
|
|
54
|
+
book_id_node = page.at("[data-book-id]")
|
|
55
|
+
book_id = if book_id_node
|
|
56
|
+
book_id_node["data-book-id"]
|
|
57
|
+
else
|
|
58
|
+
page.uri.path.split("/books/").last
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
author_link = page.at(".book-title-author-and-series a")
|
|
62
|
+
read_status_label = page.at(".read-status-label")
|
|
63
|
+
|
|
64
|
+
new({
|
|
65
|
+
"id" => book_id,
|
|
66
|
+
"title" => Util.squish(title_header.text.strip),
|
|
67
|
+
"author" => Util.squish(author_link&.text&.strip),
|
|
68
|
+
"url" => page.uri.to_s,
|
|
69
|
+
"finished_on" => extract_finish_date(page),
|
|
70
|
+
"status" => read_status_label&.text&.strip&.downcase,
|
|
71
|
+
}.merge(extra_data))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
sig { params(data: T::Hash[String, T.untyped]).void }
|
|
75
|
+
def initialize(data)
|
|
76
|
+
@data = data
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
sig { returns T.nilable(Date) }
|
|
80
|
+
def finished_on
|
|
81
|
+
return @finished_on if defined?(@finished_on)
|
|
82
|
+
date_or_str = @data["finished_on"]
|
|
83
|
+
return if date_or_str.nil?
|
|
84
|
+
@finished_on = date_or_str.is_a?(String) ? Date.parse(date_or_str) : date_or_str
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
sig { params(value: T.nilable(Date)).void }
|
|
88
|
+
def finished_on=(value)
|
|
89
|
+
@finished_on = value
|
|
90
|
+
@data["finished_on"] = value.to_s
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
sig { returns T::Boolean }
|
|
94
|
+
def finished?
|
|
95
|
+
!finished_on.nil? || status == Status::Read
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
sig { returns T::Boolean }
|
|
99
|
+
def currently_reading?
|
|
100
|
+
status == Status::CurrentlyReading
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
sig { returns T::Boolean }
|
|
104
|
+
def did_not_finish?
|
|
105
|
+
status == Status::DidNotFinish
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
sig { returns T::Boolean }
|
|
109
|
+
def want_to_read?
|
|
110
|
+
status == Status::ToRead
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
sig { params(other_book: Book).returns(T::Boolean) }
|
|
114
|
+
def copy_from(other_book)
|
|
115
|
+
unless id == other_book.id
|
|
116
|
+
raise "Cannot merge Storygraph books with different IDs: #{id} and #{other_book.id}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
puts "#{Util::INFO_EMOJI} Updating book info for #{id}..."
|
|
120
|
+
any_updates = T.let(false, T::Boolean)
|
|
121
|
+
|
|
122
|
+
other_book.to_h.each do |key, value|
|
|
123
|
+
next unless value
|
|
124
|
+
|
|
125
|
+
if @data[key].nil? || @data[key].is_a?(String) && @data[key].empty?
|
|
126
|
+
puts "#{Util::TAB}Setting #{key} => #{value}"
|
|
127
|
+
@data[key] = value
|
|
128
|
+
any_updates = true
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
any_updates
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
sig { returns T.nilable(String) }
|
|
136
|
+
def id
|
|
137
|
+
@data["id"]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
sig { returns T.nilable(String) }
|
|
141
|
+
def isbn
|
|
142
|
+
@data["isbn"]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
sig { returns T.nilable(Status) }
|
|
146
|
+
def status
|
|
147
|
+
value = @data["status"]
|
|
148
|
+
return value if value.nil? || value.is_a?(Status)
|
|
149
|
+
Status.try_deserialize(value.to_s)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
sig { params(stylize: T::Boolean).returns(T.nilable(String)) }
|
|
153
|
+
def title(stylize: false)
|
|
154
|
+
value = @data["title"]
|
|
155
|
+
return value unless stylize && value
|
|
156
|
+
Rainbow(value).underline
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
sig { returns T.nilable(String) }
|
|
160
|
+
def author
|
|
161
|
+
@data["author"]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
sig { params(stylize: T::Boolean).returns(T.nilable(String)) }
|
|
165
|
+
def url(stylize: false)
|
|
166
|
+
@url_by_stylize ||= {}
|
|
167
|
+
return @url_by_stylize[stylize] if @url_by_stylize[stylize]
|
|
168
|
+
value = @data["url"] || "#{Client::BASE_URL}/books/#{id}"
|
|
169
|
+
@url_by_stylize[stylize] = if stylize
|
|
170
|
+
Rainbow(value).blue
|
|
171
|
+
else
|
|
172
|
+
value
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
sig { params(books_db: Database::StorygraphBooks).returns(T::Boolean) }
|
|
177
|
+
def save_to_database(books_db)
|
|
178
|
+
id = self.id
|
|
179
|
+
return false unless id
|
|
180
|
+
|
|
181
|
+
books_db.upsert(id: id, title: title, author: author, finished_on: finished_on,
|
|
182
|
+
isbn: isbn, status: status&.serialize)
|
|
183
|
+
|
|
184
|
+
true
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
sig { params(indent_level: Integer, stylize: T::Boolean).returns(String) }
|
|
188
|
+
def to_s(indent_level: 0, stylize: false)
|
|
189
|
+
line1 = "#{Util::TAB * indent_level}#{title_and_author(stylize: stylize)}" \
|
|
190
|
+
"#{status_summary(stylize: stylize)}"
|
|
191
|
+
lines = [
|
|
192
|
+
line1,
|
|
193
|
+
"#{Util::TAB * (indent_level + 1)}#{Util::NEWLINE_EMOJI} #{url(stylize: stylize)}",
|
|
194
|
+
]
|
|
195
|
+
lines.join("\n")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
sig { params(stylize: T::Boolean).returns(String) }
|
|
199
|
+
def title_and_author(stylize: false)
|
|
200
|
+
@title_and_author_by_stylize ||= {}
|
|
201
|
+
return @title_and_author_by_stylize[stylize] if @title_and_author_by_stylize[stylize]
|
|
202
|
+
author = self.author
|
|
203
|
+
@title_and_author_by_stylize[stylize] = if author && !author.empty?
|
|
204
|
+
"#{title(stylize: stylize)} by #{author}"
|
|
205
|
+
else
|
|
206
|
+
title(stylize: stylize) || "Unknown (ID #{id})"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
sig { params(prefix: String, stylize: T::Boolean).returns(T.nilable(String)) }
|
|
211
|
+
def status_summary(prefix: " - ", stylize: false)
|
|
212
|
+
finished_on = self.finished_on
|
|
213
|
+
suffix = if finished_on
|
|
214
|
+
"Finished #{Util.pretty_date(finished_on)}"
|
|
215
|
+
elsif finished?
|
|
216
|
+
"Finished"
|
|
217
|
+
elsif currently_reading?
|
|
218
|
+
"Currently reading"
|
|
219
|
+
elsif did_not_finish?
|
|
220
|
+
"Did not finish"
|
|
221
|
+
elsif want_to_read?
|
|
222
|
+
"Want to read"
|
|
223
|
+
else
|
|
224
|
+
status&.serialize
|
|
225
|
+
end
|
|
226
|
+
if suffix
|
|
227
|
+
value = "#{prefix}#{suffix}"
|
|
228
|
+
stylize ? Rainbow(value).italic : value
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
sig { returns String }
|
|
233
|
+
def inspect
|
|
234
|
+
@data.inspect
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
sig { returns T::Hash[String, T.untyped] }
|
|
238
|
+
def to_h
|
|
239
|
+
@data.dup
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
sig do
|
|
243
|
+
params(node_or_page: T.any(Nokogiri::XML::Element, Mechanize::Page)).returns(T.nilable(String))
|
|
244
|
+
end
|
|
245
|
+
def self.extract_finish_date(node_or_page)
|
|
246
|
+
finish_date_prefix = "Finished "
|
|
247
|
+
finished_el = node_or_page.search(".action-menu p").detect do |el|
|
|
248
|
+
el.text.start_with?(finish_date_prefix)
|
|
249
|
+
end
|
|
250
|
+
if finished_el
|
|
251
|
+
# e.g., "Dec 20, 2024\n \n Click to edit read date"
|
|
252
|
+
date_str_and_extra = finished_el.text.split(finish_date_prefix).last
|
|
253
|
+
|
|
254
|
+
# e.g., "Dec 20, 2024"
|
|
255
|
+
date_str_and_extra.split("\n").first
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
private_class_method :extract_finish_date
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|