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,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