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,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
require "mechanize"
|
|
5
|
+
require "rainbow"
|
|
6
|
+
|
|
7
|
+
module GoodAudibleStorySync
|
|
8
|
+
module Storygraph
|
|
9
|
+
class Client
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
BASE_URL = Auth::BASE_URL
|
|
13
|
+
|
|
14
|
+
class NotAuthenticatedError < StandardError; end
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
|
|
17
|
+
sig { returns Mechanize }
|
|
18
|
+
attr_reader :agent
|
|
19
|
+
|
|
20
|
+
sig { params(auth: Auth).void }
|
|
21
|
+
def initialize(auth:)
|
|
22
|
+
@agent = T.let(auth.agent, Mechanize)
|
|
23
|
+
@auth = auth
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
sig { params(book_id: String, page: T.nilable(Mechanize::Page)).returns(T::Boolean) }
|
|
27
|
+
def mark_as_read(book_id, page: nil)
|
|
28
|
+
page ||= get_book_page(book_id)
|
|
29
|
+
action_regex = /book_id=#{book_id}&status=read/
|
|
30
|
+
form = page.forms.detect { |f| f.action =~ action_regex }
|
|
31
|
+
unless form
|
|
32
|
+
puts "#{Util::ERROR_EMOJI} Could not find form to mark book as read"
|
|
33
|
+
return false
|
|
34
|
+
end
|
|
35
|
+
form.submit
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
sig { params(book_id: String).returns(Mechanize::Page) }
|
|
40
|
+
def get_book_page(book_id)
|
|
41
|
+
page = get("/books/#{book_id}")
|
|
42
|
+
raise Error.new("Could not get book #{book_id}") unless page
|
|
43
|
+
page
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { params(book_id: String, finish_date: Date).returns(T::Boolean) }
|
|
47
|
+
def set_read_date(book_id, finish_date)
|
|
48
|
+
page = get_book_page(book_id)
|
|
49
|
+
link = get_link_to_set_read_date(page)
|
|
50
|
+
|
|
51
|
+
if link.nil? && page.at(".read-status-label").nil?
|
|
52
|
+
puts "#{Util::INFO_EMOJI} Marking book as read..."
|
|
53
|
+
success = mark_as_read(book_id, page: page)
|
|
54
|
+
return false unless success
|
|
55
|
+
|
|
56
|
+
page = get_book_page(book_id)
|
|
57
|
+
link = get_link_to_set_read_date(page)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
unless link
|
|
61
|
+
puts "#{Util::ERROR_EMOJI} Could not find link to show read-date form"
|
|
62
|
+
return false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
update_file = T.let(link.click, Mechanize::File)
|
|
66
|
+
update_page = Mechanize::Page.new(page.uri, page.response, update_file.body, page.code, @agent)
|
|
67
|
+
action_regex = /^\/read_instances\//
|
|
68
|
+
form = update_page.forms.detect { |f| f.action =~ action_regex }
|
|
69
|
+
unless form
|
|
70
|
+
puts "#{Util::ERROR_EMOJI} Could not find form to update read date"
|
|
71
|
+
return false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
end_day_field = form.field_with(name: "read_instance[day]")
|
|
75
|
+
unless end_day_field
|
|
76
|
+
puts "#{Util::ERROR_EMOJI} Could not find day field in read-date form"
|
|
77
|
+
return false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
end_month_field = form.field_with(name: "read_instance[month]")
|
|
81
|
+
unless end_month_field
|
|
82
|
+
puts "#{Util::ERROR_EMOJI} Could not find month field in read-date form"
|
|
83
|
+
return false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
end_year_field = form.field_with(name: "read_instance[year]")
|
|
87
|
+
unless end_year_field
|
|
88
|
+
puts "#{Util::ERROR_EMOJI} Could not find year field in read-date form"
|
|
89
|
+
return false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
end_year_field.value = finish_date.year
|
|
93
|
+
end_month_field.value = finish_date.month
|
|
94
|
+
end_day_field.value = finish_date.day
|
|
95
|
+
|
|
96
|
+
form.submit
|
|
97
|
+
|
|
98
|
+
true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
sig { params(book_id: String).returns(T::Boolean) }
|
|
102
|
+
def set_currently_reading(book_id)
|
|
103
|
+
page = get_book_page(book_id)
|
|
104
|
+
action_regex = /book_id=#{book_id}&status=currently-reading$/
|
|
105
|
+
form = page.forms.detect { |f| f.action =~ action_regex }
|
|
106
|
+
return false unless form
|
|
107
|
+
form.submit
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
sig { params(path: String).returns(T.nilable(Mechanize::Page)) }
|
|
112
|
+
def get(path)
|
|
113
|
+
url = "#{BASE_URL}#{path}"
|
|
114
|
+
puts "#{Util::INFO_EMOJI} GET #{Rainbow(url).blue}"
|
|
115
|
+
load_page(-> { @agent.get(url) })
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# e.g., https://app.thestorygraph.com/books-read/cheshire137
|
|
119
|
+
sig do
|
|
120
|
+
params(
|
|
121
|
+
page: Integer,
|
|
122
|
+
load_all_pages: T::Boolean,
|
|
123
|
+
process_book: T.nilable(T.proc.params(arg0: Book).void)
|
|
124
|
+
).returns(Library)
|
|
125
|
+
end
|
|
126
|
+
def get_read_books(page: 1, load_all_pages: true, process_book: nil)
|
|
127
|
+
initial_page = get("/books-read/#{@auth.username}?page=#{page}")
|
|
128
|
+
raise Error.new("Could not load page #{page} of read books") unless initial_page
|
|
129
|
+
|
|
130
|
+
filter_header_prefix = "Filter list "
|
|
131
|
+
filter_header_el = initial_page.search(".filter-menu *").detect do |el|
|
|
132
|
+
el.text.start_with?(filter_header_prefix)
|
|
133
|
+
end
|
|
134
|
+
total_books = if filter_header_el
|
|
135
|
+
filter_header_el.text.split(filter_header_prefix).last.gsub(/[^0-9]/, "").to_i
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
library = Library.new(total_books: total_books)
|
|
139
|
+
books = get_read_books_on_page(page: initial_page, load_all_pages: load_all_pages,
|
|
140
|
+
process_book: process_book)
|
|
141
|
+
|
|
142
|
+
books.each { |book| library.add_book(book) }
|
|
143
|
+
library
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
sig { params(isbn: String, fallback_query: T.nilable(String)).returns(T.nilable(Book)) }
|
|
147
|
+
def find_by_isbn(isbn, fallback_query: nil)
|
|
148
|
+
result_link = search(isbn).first
|
|
149
|
+
if result_link.nil? && fallback_query
|
|
150
|
+
puts "#{Util::WARNING_EMOJI} No results for ISBN #{isbn}, searching for '#{fallback_query}'"
|
|
151
|
+
result_link = search(fallback_query).first
|
|
152
|
+
end
|
|
153
|
+
return unless result_link
|
|
154
|
+
|
|
155
|
+
load_book_search_result(result_link, extra_data: { "isbn" => isbn })
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
sig { params(link: Mechanize::Page::Link, extra_data: T::Hash[String, T.untyped]).returns(Book) }
|
|
159
|
+
def load_book_search_result(link, extra_data: {})
|
|
160
|
+
page = link.click
|
|
161
|
+
|
|
162
|
+
other_edition_link = page.link_with(text: /You've read another edition/) ||
|
|
163
|
+
page.link_with(text: /You did not finish another edition/)
|
|
164
|
+
if other_edition_link
|
|
165
|
+
page = other_edition_link.click
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
Book.from_book_page(page, extra_data: extra_data)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# e.g., https://app.thestorygraph.com/search?search_term=midnight%20chernobyl
|
|
172
|
+
sig { params(query: String).returns(T::Array[Mechanize::Page::Link]) }
|
|
173
|
+
def search(query)
|
|
174
|
+
raise "No search query provided" if query.strip.empty?
|
|
175
|
+
|
|
176
|
+
params = { "search_term" => query }
|
|
177
|
+
page = get("/search?#{URI.encode_www_form(params)}")
|
|
178
|
+
raise Error.new("Could not load search results for '#{query}'") unless page
|
|
179
|
+
|
|
180
|
+
search_results_list = page.at("#search-results-ul")
|
|
181
|
+
return [] unless search_results_list
|
|
182
|
+
|
|
183
|
+
links = page.links.select { |link| link.node.ancestors.include?(search_results_list) }
|
|
184
|
+
links.reject { |link| link.text.strip.start_with?("View all results") }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
sig { params(page: Mechanize::Page).returns(T.nilable(Mechanize::Page::Link)) }
|
|
190
|
+
def get_link_to_set_read_date(page)
|
|
191
|
+
page.link_with(text: /Click to add a read date/) ||
|
|
192
|
+
page.link_with(text: /Click to edit read date/)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
sig { params(make_request: T.proc.returns(Mechanize::Page)).returns(T.nilable(Mechanize::Page)) }
|
|
196
|
+
def load_page(make_request)
|
|
197
|
+
page = begin
|
|
198
|
+
make_request.call
|
|
199
|
+
rescue Mechanize::ResponseCodeError => err
|
|
200
|
+
puts "#{Util::ERROR_EMOJI} Error loading page: #{err}"
|
|
201
|
+
return nil
|
|
202
|
+
end
|
|
203
|
+
raise NotAuthenticatedError if Auth.sign_in_page?(page)
|
|
204
|
+
sleep 1 # don't hammer the server
|
|
205
|
+
page
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
sig do
|
|
209
|
+
params(
|
|
210
|
+
path: T.nilable(String),
|
|
211
|
+
page: T.nilable(Mechanize::Page),
|
|
212
|
+
load_all_pages: T::Boolean,
|
|
213
|
+
process_book: T.nilable(T.proc.params(arg0: Book).void)
|
|
214
|
+
).returns(T::Array[Book])
|
|
215
|
+
end
|
|
216
|
+
def get_read_books_on_page(path: nil, page: nil, load_all_pages: true, process_book: nil)
|
|
217
|
+
if path
|
|
218
|
+
page = get(path)
|
|
219
|
+
raise Error.new("Could not load read books via page #{path}") unless page
|
|
220
|
+
elsif page.nil?
|
|
221
|
+
raise "Either a relative URL or a page must be provided"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
book_elements = T.let(page.search(".read-books-panes .book-pane"), Nokogiri::XML::NodeSet)
|
|
225
|
+
books = book_elements.map { |book_element| Book.from_read_book(book_element, page: page) }
|
|
226
|
+
puts "#{Util::WARNING_EMOJI} No books found on #{page.uri}" if books.empty?
|
|
227
|
+
books.each { |book| process_book.call(book) } if process_book
|
|
228
|
+
|
|
229
|
+
if load_all_pages
|
|
230
|
+
next_page_link = page.at(".read-books #next_link")
|
|
231
|
+
if next_page_link
|
|
232
|
+
units = books.size == 1 ? "book" : "books"
|
|
233
|
+
print "#{Util::INFO_EMOJI} Found #{books.size} #{units} on page"
|
|
234
|
+
last_book = books.last
|
|
235
|
+
print ", ending with #{last_book.title_and_author}" if last_book
|
|
236
|
+
puts
|
|
237
|
+
|
|
238
|
+
books += get_read_books_on_page(path: next_page_link["href"],
|
|
239
|
+
load_all_pages: load_all_pages, process_book: process_book)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
books
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
module GoodAudibleStorySync
|
|
5
|
+
module Storygraph
|
|
6
|
+
class Library
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
SYNC_TIME_KEY = "storygraph_library"
|
|
10
|
+
|
|
11
|
+
sig { params(client: Client, db_client: Database::Client, options: Options).returns(Library) }
|
|
12
|
+
def self.load(client:, db_client:, options:)
|
|
13
|
+
library_cache_last_modified = db_client.sync_times.find(SYNC_TIME_KEY)&.to_time
|
|
14
|
+
library_is_cached = !library_cache_last_modified.nil?
|
|
15
|
+
should_refresh_library = library_cache_last_modified &&
|
|
16
|
+
library_cache_last_modified > options.refresh_cutoff_time
|
|
17
|
+
|
|
18
|
+
if library_is_cached && should_refresh_library
|
|
19
|
+
load_from_database(db_client.storygraph_books)
|
|
20
|
+
else
|
|
21
|
+
if library_is_cached
|
|
22
|
+
puts "#{Util::INFO_EMOJI} Storygraph library cache has not been updated " \
|
|
23
|
+
"since #{Util.pretty_time(library_cache_last_modified)}, updating..."
|
|
24
|
+
end
|
|
25
|
+
load_from_web(client: client, db_client: db_client)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { params(client: Client, db_client: Database::Client).returns(Library) }
|
|
30
|
+
def self.load_from_web(client:, db_client:)
|
|
31
|
+
books_db = db_client.storygraph_books
|
|
32
|
+
save_book = T.let(
|
|
33
|
+
->(book) { book.save_to_database(books_db) },
|
|
34
|
+
T.proc.params(arg0: GoodAudibleStorySync::Storygraph::Book).void
|
|
35
|
+
)
|
|
36
|
+
begin
|
|
37
|
+
library = client.get_read_books(process_book: save_book)
|
|
38
|
+
library.update_sync_time(db_client.sync_times)
|
|
39
|
+
library
|
|
40
|
+
rescue Client::Error => err
|
|
41
|
+
puts "#{Util::ERROR_EMOJI} Error loading Storygraph library: #{err}"
|
|
42
|
+
exit 1
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { params(books_db: Database::StorygraphBooks).returns(Library) }
|
|
47
|
+
def self.load_from_database(books_db)
|
|
48
|
+
library = new
|
|
49
|
+
library.load_from_database(books_db)
|
|
50
|
+
library
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sig { params(total_books: T.nilable(Integer)).void }
|
|
54
|
+
def initialize(total_books: nil)
|
|
55
|
+
@books_by_id = T.let({}, T::Hash[String, Book])
|
|
56
|
+
@total_books = total_books
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sig { returns T::Array[Book] }
|
|
60
|
+
def books
|
|
61
|
+
@books_by_id.values
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
sig { params(book: Book).void }
|
|
65
|
+
def add_book(book)
|
|
66
|
+
id = book.id
|
|
67
|
+
raise "Cannot add book without ID" unless id
|
|
68
|
+
|
|
69
|
+
existing_book = @books_by_id[id]
|
|
70
|
+
@books_by_id[id] = if existing_book
|
|
71
|
+
existing_book.copy_from(book)
|
|
72
|
+
existing_book
|
|
73
|
+
else
|
|
74
|
+
book
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
sig { params(book: Book).void }
|
|
79
|
+
def remove_book(book)
|
|
80
|
+
book_id = book.id
|
|
81
|
+
@books_by_id.delete(book_id) if book_id
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
sig { returns Integer }
|
|
85
|
+
def total_books
|
|
86
|
+
@total_books || @books_by_id.size
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
sig { returns Integer }
|
|
90
|
+
def finished_percent
|
|
91
|
+
@finished_percent ||= (total_finished.to_f / total_books * 100).round
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
sig { returns String }
|
|
95
|
+
def finished_book_units
|
|
96
|
+
total_finished == 1 ? "book" : "books"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
sig { returns String }
|
|
100
|
+
def book_units
|
|
101
|
+
total_books == 1 ? "book" : "books"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
sig { params(books_db: Database::StorygraphBooks).returns(T::Boolean) }
|
|
105
|
+
def load_from_database(books_db)
|
|
106
|
+
puts "#{Util::INFO_EMOJI} Loading cached Storygraph library..."
|
|
107
|
+
|
|
108
|
+
rows = books_db.find_all
|
|
109
|
+
rows.each { |row| add_book(Book.new(row)) }
|
|
110
|
+
|
|
111
|
+
true
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
sig { params(sync_times_db: Database::SyncTimes).void }
|
|
115
|
+
def update_sync_time(sync_times_db)
|
|
116
|
+
puts "#{Util::SAVE_EMOJI} Updating time Storygraph library was last cached..."
|
|
117
|
+
sync_times_db.touch(SYNC_TIME_KEY)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
sig { params(limit: Integer, stylize: T::Boolean).returns(String) }
|
|
121
|
+
def to_s(limit: 5, stylize: false)
|
|
122
|
+
[
|
|
123
|
+
"📚 Found #{total_books} #{book_units} on Storygraph",
|
|
124
|
+
finished_books_summary(limit: limit, stylize: stylize),
|
|
125
|
+
].compact.join("\n")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
sig { params(limit: Integer, stylize: T::Boolean).returns(String) }
|
|
129
|
+
def finished_books_summary(limit: 5, stylize: false)
|
|
130
|
+
lines = T.let([
|
|
131
|
+
"#{Util::DONE_EMOJI} #{total_finished} #{finished_book_units} " \
|
|
132
|
+
"(#{finished_percent}%) in Storygraph library have been finished:",
|
|
133
|
+
], T::Array[String])
|
|
134
|
+
lines.concat(finished_books.take(limit).map { |book| book.to_s(indent_level: 1, stylize: stylize) })
|
|
135
|
+
lines << "#{Util::TAB}..." if total_finished > limit
|
|
136
|
+
lines << ""
|
|
137
|
+
lines.join("\n")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
sig { returns Integer }
|
|
141
|
+
def total_finished
|
|
142
|
+
@total_finished ||= finished_books.size
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
sig { returns T::Array[Book] }
|
|
146
|
+
def finished_books
|
|
147
|
+
calculate_finished_unfinished_books
|
|
148
|
+
@finished_books
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
sig { returns String }
|
|
152
|
+
def to_json
|
|
153
|
+
JSON.pretty_generate(books.map(&:to_h))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
sig { params(isbn: String).returns(T.nilable(Book)) }
|
|
157
|
+
def find_by_isbn(isbn)
|
|
158
|
+
books.detect { |book| book.isbn == isbn }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
sig { void }
|
|
164
|
+
def calculate_finished_unfinished_books
|
|
165
|
+
return if @finished_books && @unfinished_books
|
|
166
|
+
@finished_books, @unfinished_books = books.partition(&:finished?)
|
|
167
|
+
@finished_books.sort! do |a, b|
|
|
168
|
+
a_finish_date = a.finished_on
|
|
169
|
+
b_finish_date = b.finished_on
|
|
170
|
+
if a_finish_date && b_finish_date
|
|
171
|
+
T.must(b_finish_date <=> a_finish_date)
|
|
172
|
+
elsif a_finish_date
|
|
173
|
+
-1
|
|
174
|
+
elsif b_finish_date
|
|
175
|
+
1
|
|
176
|
+
else
|
|
177
|
+
0
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
require "rainbow"
|
|
5
|
+
|
|
6
|
+
module GoodAudibleStorySync
|
|
7
|
+
module Storygraph
|
|
8
|
+
class LookUpBookFlow
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
class UserCommand < T::Enum
|
|
12
|
+
enums do
|
|
13
|
+
Cancel = new("c")
|
|
14
|
+
Quit = new("q")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
sig { params(client: Client, books_db: Database::StorygraphBooks).void }
|
|
19
|
+
def self.run(client:, books_db:)
|
|
20
|
+
new(client: client, books_db: books_db).run
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { params(client: Client, books_db: Database::StorygraphBooks).void }
|
|
24
|
+
def initialize(client:, books_db:)
|
|
25
|
+
@client = client
|
|
26
|
+
@books_db = books_db
|
|
27
|
+
@results = T.let([], T::Array[Mechanize::Page::Link])
|
|
28
|
+
@query = T.let(nil, T.nilable(String))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { void }
|
|
32
|
+
def run
|
|
33
|
+
loop do
|
|
34
|
+
print_options
|
|
35
|
+
cmd_or_query = get_user_command_or_query
|
|
36
|
+
if cmd_or_query.is_a?(UserCommand)
|
|
37
|
+
process_command(cmd_or_query)
|
|
38
|
+
else
|
|
39
|
+
@query = cmd_or_query
|
|
40
|
+
search_storygraph
|
|
41
|
+
end
|
|
42
|
+
puts
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
sig { void }
|
|
49
|
+
def print_options
|
|
50
|
+
display_search_results if @results.size > 0
|
|
51
|
+
print_option(UserCommand::Cancel, "cancel")
|
|
52
|
+
print_option(UserCommand::Quit, "quit")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
sig { params(option: UserCommand, description: String).void }
|
|
56
|
+
def print_option(option, description)
|
|
57
|
+
Util.print_option(option.serialize, description)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
sig { returns T.any(UserCommand, String) }
|
|
61
|
+
def get_user_command_or_query
|
|
62
|
+
input = T.let(nil, T.nilable(String))
|
|
63
|
+
while input.nil?
|
|
64
|
+
print "Enter a search query or command: "
|
|
65
|
+
input = gets.chomp
|
|
66
|
+
if input.empty?
|
|
67
|
+
puts "Invalid selection"
|
|
68
|
+
else
|
|
69
|
+
cmd = UserCommand.try_deserialize(input)
|
|
70
|
+
return cmd if cmd
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
input
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
sig { params(cmd: UserCommand).void }
|
|
77
|
+
def process_command(cmd)
|
|
78
|
+
case cmd
|
|
79
|
+
when UserCommand::Cancel then cancel
|
|
80
|
+
when UserCommand::Quit then quit
|
|
81
|
+
else
|
|
82
|
+
T.absurd(cmd)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
sig { params(selection: String).void }
|
|
87
|
+
def process_search_result_selection(selection)
|
|
88
|
+
unless Util.integer?(selection)
|
|
89
|
+
puts "#{Util::ERROR_EMOJI} Invalid selection"
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
index = selection.to_i - 1
|
|
94
|
+
result = @results[index]
|
|
95
|
+
unless result
|
|
96
|
+
puts "#{Util::ERROR_EMOJI} Invalid selection"
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
extra_data = {}
|
|
101
|
+
if @query && Util.isbn?(@query)
|
|
102
|
+
extra_data["isbn"] = @query
|
|
103
|
+
end
|
|
104
|
+
book = @client.load_book_search_result(result, extra_data: extra_data)
|
|
105
|
+
book.save_to_database(@books_db)
|
|
106
|
+
puts book.to_s(stylize: true)
|
|
107
|
+
@results = []
|
|
108
|
+
@query = nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
sig { void }
|
|
112
|
+
def search_storygraph
|
|
113
|
+
raise "No search query provided" if @query.nil?
|
|
114
|
+
@results = @client.search(@query)
|
|
115
|
+
if @results.empty?
|
|
116
|
+
puts "#{Util::INFO_EMOJI} No results found for \"#{@query}\""
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
prompt_user_to_pick_search_result
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
sig { void }
|
|
123
|
+
def display_search_results
|
|
124
|
+
total_results = @results.size
|
|
125
|
+
units = total_results == 1 ? "result" : "results"
|
|
126
|
+
puts "#{Util::INFO_EMOJI} Showing #{total_results} search #{units}:"
|
|
127
|
+
@results.each_with_index do |link, i|
|
|
128
|
+
display_search_result(link, i + 1)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
sig { params(link: Mechanize::Page::Link, number: Integer).void }
|
|
133
|
+
def display_search_result(link, number)
|
|
134
|
+
node = link.node
|
|
135
|
+
title = Util.squish(node.at("h1:not(.sr-only)")&.text)
|
|
136
|
+
author = Util.squish(node.at("h2:not(.sr-only)")&.text)
|
|
137
|
+
highlighted_number = Rainbow(number.to_s).green
|
|
138
|
+
print "#{highlighted_number}) "
|
|
139
|
+
if title && author
|
|
140
|
+
puts Rainbow(title).underline + " by #{author}"
|
|
141
|
+
else
|
|
142
|
+
puts link.text
|
|
143
|
+
end
|
|
144
|
+
url = Rainbow(link.resolved_uri.to_s).blue
|
|
145
|
+
puts "#{Util::TAB}#{Util::NEWLINE_EMOJI} #{url}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
sig { void }
|
|
149
|
+
def prompt_user_to_pick_search_result
|
|
150
|
+
print_options
|
|
151
|
+
print "Make a selection: "
|
|
152
|
+
input = gets.chomp
|
|
153
|
+
cmd = UserCommand.try_deserialize(input)
|
|
154
|
+
if cmd
|
|
155
|
+
process_command(cmd)
|
|
156
|
+
else
|
|
157
|
+
process_search_result_selection(input)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
sig { void }
|
|
162
|
+
def cancel
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
sig { void }
|
|
166
|
+
def quit
|
|
167
|
+
puts "Goodbye!"
|
|
168
|
+
exit 0
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|