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,137 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require "mechanize"
5
+
6
+ module GoodAudibleStorySync
7
+ module Goodreads
8
+ class Auth
9
+ extend T::Sig
10
+
11
+ class Error < StandardError; end
12
+
13
+ BASE_URL = "https://www.goodreads.com"
14
+ CREDENTIALS_DB_KEY = "goodreads"
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
+ url = page.uri.to_s
26
+ url.end_with?("/user/sign_in") || url.end_with?("/ap/signin")
27
+ end
28
+
29
+ sig { returns T.nilable(String) }
30
+ attr_reader :email, :profile_name, :user_id, :slug
31
+
32
+ sig { returns Mechanize }
33
+ attr_reader :agent
34
+
35
+ sig { params(agent: T.nilable(Mechanize), data: T::Hash[String, T.nilable(String)]).void }
36
+ def initialize(agent: nil, data: {})
37
+ @agent = agent || Mechanize.new
38
+ @agent.user_agent_alias = "iPad"
39
+ @email = T.let(data["email"], T.nilable(String))
40
+ @password = T.let(data["password"], T.nilable(String))
41
+ @profile_name = T.let(data["profile_name"], T.nilable(String))
42
+ @user_id = T.let(data["user_id"], T.nilable(String))
43
+ @slug = T.let(data["slug"], T.nilable(String))
44
+ end
45
+
46
+ sig { returns(T.untyped) }
47
+ def sign_in
48
+ raise Error.new("Cannot sign in without credentials") if @email.nil? || @password.nil?
49
+
50
+ puts "#{Util::INFO_EMOJI} Signing into Goodreads as #{@email}..."
51
+ select_sign_in_page = begin
52
+ get("/user/sign_in")
53
+ rescue Errno::ETIMEDOUT => err
54
+ raise Error.new("Failed to load Goodreads: #{err}")
55
+ end
56
+
57
+ email_sign_in_link = T.let(select_sign_in_page.link_with(text: /Sign in with email/),
58
+ T.nilable(Mechanize::Page::Link))
59
+ raise Error.new("Failed to find sign-in link on #{select_sign_in_page.uri}") unless email_sign_in_link
60
+
61
+ email_sign_in_page = email_sign_in_link.click
62
+ sign_in_form = T.let(email_sign_in_page.form_with(name: "signIn"), T.nilable(Mechanize::Form))
63
+ raise Error.new("Could not find sign-in form on #{email_sign_in_page.uri}") unless sign_in_form
64
+
65
+ email_field = sign_in_form.field_with(name: "email")
66
+ raise Error.new("Could not find email field in sign-in form") unless email_field
67
+ password_field = sign_in_form.field_with(name: "password")
68
+ raise Error.new("Could not find password field in sign-in form") unless password_field
69
+
70
+ email_field.value = @email
71
+ password_field.value = @password
72
+ page_after_sign_in = begin
73
+ sign_in_form.submit
74
+ rescue Mechanize::ResponseCodeError => err
75
+ raise Error.new("Error signing into Goodreads: #{err}")
76
+ end
77
+
78
+ cookieless_message_el = page_after_sign_in.at("#ap_error_page_cookieless_message")
79
+ if cookieless_message_el
80
+ raise Error.new("Could not sign in to Goodreads without cookies: " \
81
+ "#{Util.squish(cookieless_message_el.text)}")
82
+ end
83
+
84
+ profile_link = T.let(page_after_sign_in.link_with(text: "Profile"), T.nilable(Mechanize::Page::Link))
85
+ successful_sign_in = !profile_link.nil? && !self.class.sign_in_page?(page_after_sign_in)
86
+ raise Error.new("Could not log in to Goodreads") unless successful_sign_in
87
+
88
+ profile_page = profile_link.click
89
+ profile_header = profile_page.at("h1")
90
+ @profile_name = if profile_header
91
+ profile_header.text.strip.split(/\n/).first
92
+ end
93
+ user_id_and_slug = profile_page.uri.path.split("/").last # e.g., "21047466-cheshire"
94
+ @user_id, @slug = user_id_and_slug.split("-")
95
+ puts "#{Util::SUCCESS_EMOJI} Signed in to Goodreads as #{@profile_name}"
96
+ end
97
+
98
+ sig { params(path: String).returns(Mechanize::Page) }
99
+ def get(path)
100
+ @agent.get("#{BASE_URL}#{path}")
101
+ end
102
+
103
+ sig { returns T::Hash[String, T.untyped] }
104
+ def to_h
105
+ {
106
+ "email" => @email,
107
+ "password" => @password,
108
+ "profile_name" => @profile_name,
109
+ "user_id" => @user_id,
110
+ "slug" => @slug,
111
+ }
112
+ end
113
+
114
+ sig { params(cred_client: Database::Credentials).void }
115
+ def save_to_database(cred_client)
116
+ cred_client.upsert(key: CREDENTIALS_DB_KEY, value: to_h)
117
+ end
118
+
119
+ sig { params(cred_client: Database::Credentials).returns(T::Boolean) }
120
+ def load_from_database(cred_client)
121
+ goodreads_data = cred_client.find(key: CREDENTIALS_DB_KEY)
122
+ unless goodreads_data
123
+ puts "#{Util::INFO_EMOJI} No Goodreads credentials found in database"
124
+ return false
125
+ end
126
+
127
+ @email = goodreads_data["email"]
128
+ @password = goodreads_data["password"]
129
+ @profile_name = goodreads_data["profile_name"]
130
+ @user_id = goodreads_data["user_id"]
131
+ @slug = goodreads_data["slug"]
132
+
133
+ true
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require "io/console"
5
+
6
+ module GoodAudibleStorySync
7
+ module Goodreads
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 Goodreads."
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 Goodreads credentials in database..."
35
+ success = auth.load_from_database(@credentials_db)
36
+ puts "#{Util::SUCCESS_EMOJI} Found saved Goodreads 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 Goodreads: #{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 Goodreads..."
52
+ print "Enter Goodreads email: "
53
+ email = gets.chomp
54
+ print "Enter Goodreads 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 Goodreads: #{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,171 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require "rainbow"
5
+ require "uri"
6
+
7
+ module GoodAudibleStorySync
8
+ module Goodreads
9
+ class Book
10
+ extend T::Sig
11
+
12
+ class Status < T::Enum
13
+ enums do
14
+ CurrentlyReading = new("currently-reading")
15
+ WantToRead = new("to-read")
16
+ Read = new("read")
17
+ end
18
+ end
19
+
20
+ # Public: From a `table#books tbody tr` element on a page like
21
+ # https://www.goodreads.com/review/list/21047466-cheshire?shelf=read.
22
+ sig { params(node: Nokogiri::XML::Element, page: Mechanize::Page).returns(Book) }
23
+ def self.from_book_list(node, page:)
24
+ name_el = node.at(".field.title .value")
25
+ raise "Could not find itemprop=name element for book list item on #{node.document.url}" unless name_el
26
+ link = name_el.at("a")
27
+ raise "Could not find book link for #{name_el.text} on #{node.document.url}" unless link
28
+ url = URI.parse(link["href"])
29
+ url.hostname ||= page.uri.hostname
30
+ url.scheme ||= page.uri.scheme
31
+ author_el = node.at(".field.author .value")
32
+ author = if author_el
33
+ value = author_el.text.strip
34
+ if value.include?(", ") # e.g., "Simmons, Dan"
35
+ value = value.split(", ").reverse.join(" ")
36
+ end
37
+ value
38
+ end
39
+ current_shelf_link = node.at(".field.shelves .value a")
40
+ isbn_el = node.at(".field.isbn .value")
41
+ new({
42
+ "title" => Util.squish(name_el.text),
43
+ "url" => url, # e.g., "https://www.goodreads.com/book/show/11286.Carrion_Comfort"
44
+ "slug" => url.path&.split("/")&.last, # e.g., "11286.Carrion_Comfort"
45
+ "author" => author,
46
+ "status" => current_shelf_link&.text, # e.g., "to-read"
47
+ "isbn" => isbn_el&.text&.strip,
48
+ })
49
+ end
50
+
51
+ sig { params(data: T::Hash[String, T.untyped]).void }
52
+ def initialize(data)
53
+ @data = data
54
+ raise "Cannot create a Goodreads book without a slug" unless @data["slug"] && !@data["slug"].empty?
55
+ end
56
+
57
+ sig { returns String }
58
+ def slug
59
+ @data["slug"]
60
+ end
61
+
62
+ sig { returns T.nilable(String) }
63
+ def isbn
64
+ @data["isbn"]
65
+ end
66
+
67
+ sig { params(stylize: T::Boolean).returns(T.nilable(String)) }
68
+ def title(stylize: false)
69
+ value = @data["title"]
70
+ return value unless stylize && value
71
+ Rainbow(value).underline
72
+ end
73
+
74
+ sig { returns T.nilable(String) }
75
+ def author
76
+ @data["author"]
77
+ end
78
+
79
+ sig { returns T.nilable(Status) }
80
+ def status
81
+ value = @data["status"]
82
+ return value if value.nil? || value.is_a?(Status)
83
+ Status.try_deserialize(value.to_s)
84
+ end
85
+
86
+ sig { returns T::Boolean }
87
+ def finished?
88
+ status == Status::Read
89
+ end
90
+
91
+ sig { returns T::Boolean }
92
+ def currently_reading?
93
+ status == Status::CurrentlyReading
94
+ end
95
+
96
+ sig { returns T::Boolean }
97
+ def want_to_read?
98
+ status == Status::WantToRead
99
+ end
100
+
101
+ sig { params(stylize: T::Boolean).returns(T.nilable(String)) }
102
+ def url(stylize: false)
103
+ @url_by_stylize ||= {}
104
+ return @url_by_stylize[stylize] if @url_by_stylize[stylize]
105
+ value = @data["url"] || "#{Client::BASE_URL}/book/show/#{slug}"
106
+ @url_by_stylize[stylize] = if stylize
107
+ Rainbow(value).blue
108
+ else
109
+ value
110
+ end
111
+ end
112
+
113
+ sig { params(stylize: T::Boolean).returns(String) }
114
+ def title_and_author(stylize: false)
115
+ @title_and_author_by_stylize ||= {}
116
+ return @title_and_author_by_stylize[stylize] if @title_and_author_by_stylize[stylize]
117
+ author = self.author
118
+ @title_and_author_by_stylize[stylize] = if author && !author.empty?
119
+ "#{title(stylize: stylize)} by #{author}"
120
+ else
121
+ title(stylize: stylize) || "Unknown (slug #{slug})"
122
+ end
123
+ end
124
+
125
+ sig { params(indent_level: Integer, stylize: T::Boolean).returns(String) }
126
+ def to_s(indent_level: 0, stylize: false)
127
+ line1 = "#{Util::TAB * indent_level}#{title_and_author(stylize: stylize)}" \
128
+ "#{status_summary(stylize: stylize)}"
129
+ lines = [
130
+ line1,
131
+ "#{Util::TAB * (indent_level + 1)}#{Util::NEWLINE_EMOJI} #{url(stylize: stylize)}",
132
+ ]
133
+ lines.join("\n")
134
+ end
135
+
136
+ sig { returns String }
137
+ def inspect
138
+ @data.inspect
139
+ end
140
+
141
+ sig { returns T::Hash[String, T.untyped] }
142
+ def to_h
143
+ @data.dup
144
+ end
145
+
146
+ sig { params(books_db: Database::GoodreadsBooks).returns(T::Boolean) }
147
+ def save_to_database(books_db)
148
+ books_db.upsert(slug: slug, title: title, author: author,
149
+ isbn: isbn, status: status&.serialize)
150
+ true
151
+ end
152
+
153
+ sig { params(prefix: String, stylize: T::Boolean).returns(T.nilable(String)) }
154
+ def status_summary(prefix: " - ", stylize: false)
155
+ suffix = if finished?
156
+ "Finished"
157
+ elsif currently_reading?
158
+ "Currently reading"
159
+ elsif want_to_read?
160
+ "Want to read"
161
+ else
162
+ status&.serialize
163
+ end
164
+ if suffix
165
+ value = "#{prefix}#{suffix}"
166
+ stylize ? Rainbow(value).italic : value
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require "mechanize"
5
+ require "rainbow"
6
+
7
+ module GoodAudibleStorySync
8
+ module Goodreads
9
+ class Client
10
+ extend T::Sig
11
+
12
+ BASE_URL = Auth::BASE_URL
13
+
14
+ class Error < StandardError; end
15
+ class NotAuthenticatedError < 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
+ # e.g., https://www.goodreads.com/review/list/21047466-cheshire?shelf=read
27
+ sig do
28
+ params(
29
+ page: Integer,
30
+ load_all_pages: T::Boolean,
31
+ process_book: T.nilable(T.proc.params(arg0: Book).void)
32
+ ).returns(Library)
33
+ end
34
+ def get_read_books(page: 1, load_all_pages: true, process_book: nil)
35
+ initial_page = get("/review/list/#{@auth.user_id}-#{@auth.slug}?shelf=read&page=#{page}")
36
+ library = Library.new
37
+ books = get_read_books_on_page(page: initial_page, load_all_pages: load_all_pages,
38
+ process_book: process_book)
39
+ books.each { |book| library.add_book(book) }
40
+ library
41
+ end
42
+
43
+ sig { params(path: String).returns(Mechanize::Page) }
44
+ def get(path)
45
+ url = "#{BASE_URL}#{path}"
46
+ puts "#{Util::INFO_EMOJI} GET #{Rainbow(url).blue}"
47
+ load_page(-> { @agent.get(url) })
48
+ end
49
+
50
+ private
51
+
52
+ sig do
53
+ params(
54
+ path: T.nilable(String),
55
+ page: T.nilable(Mechanize::Page),
56
+ load_all_pages: T::Boolean,
57
+ process_book: T.nilable(T.proc.params(arg0: Book).void)
58
+ ).returns(T::Array[Book])
59
+ end
60
+ def get_read_books_on_page(path: nil, page: nil, load_all_pages: true, process_book: nil)
61
+ if path
62
+ page = get(path)
63
+ elsif page.nil?
64
+ raise "Either a relative URL or a page must be provided"
65
+ end
66
+
67
+ book_elements = T.let(page.search("table#books tbody tr"), Nokogiri::XML::NodeSet)
68
+ books = book_elements.map { |el| Book.from_book_list(el, page: page) }
69
+ puts "#{Util::WARNING_EMOJI} No books found on #{page.uri}" if books.empty?
70
+ books.each { |book| process_book.call(book) } if process_book
71
+
72
+ if load_all_pages
73
+ next_page_link = page.at("a.next_page")
74
+ if next_page_link
75
+ units = books.size == 1 ? "book" : "books"
76
+ print "#{Util::INFO_EMOJI} Found #{books.size} #{units} on page"
77
+ last_book = books.last
78
+ print ", ending with #{last_book.title_and_author}" if last_book
79
+ puts
80
+
81
+ books += get_read_books_on_page(path: next_page_link["href"],
82
+ load_all_pages: load_all_pages, process_book: process_book)
83
+ end
84
+ end
85
+
86
+ books
87
+ end
88
+
89
+ sig { params(make_request: T.proc.returns(Mechanize::Page)).returns(Mechanize::Page) }
90
+ def load_page(make_request)
91
+ page = make_request.call
92
+ raise NotAuthenticatedError if Auth.sign_in_page?(page)
93
+ sleep 1 # don't hammer the server
94
+ page
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ module GoodAudibleStorySync
5
+ module Goodreads
6
+ class Library
7
+ extend T::Sig
8
+
9
+ SYNC_TIME_KEY = "goodreads_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.goodreads_books)
20
+ else
21
+ if library_is_cached
22
+ puts "#{Util::INFO_EMOJI} Goodreads 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.goodreads_books
32
+ save_book = T.let(
33
+ ->(book) { book.save_to_database(books_db) },
34
+ T.proc.params(arg0: GoodAudibleStorySync::Goodreads::Book).void
35
+ )
36
+ library = client.get_read_books(process_book: save_book)
37
+ library.update_sync_time(db_client.sync_times)
38
+ library
39
+ end
40
+
41
+ sig { params(books_db: Database::GoodreadsBooks).returns(Library) }
42
+ def self.load_from_database(books_db)
43
+ library = new
44
+ library.load_from_database(books_db)
45
+ library
46
+ end
47
+
48
+ sig { void }
49
+ def initialize
50
+ @books_by_slug = T.let({}, T::Hash[String, Book])
51
+ end
52
+
53
+ sig { returns T::Array[Book] }
54
+ def books
55
+ @books_by_slug.values
56
+ end
57
+
58
+ sig { params(book: Book).void }
59
+ def add_book(book)
60
+ @books_by_slug[book.slug] = book
61
+ end
62
+
63
+ sig { returns T::Array[Book] }
64
+ def finished_books
65
+ calculate_finished_unfinished_books
66
+ @finished_books
67
+ end
68
+
69
+ sig { returns Integer }
70
+ def total_finished
71
+ @total_finished ||= finished_books.size
72
+ end
73
+
74
+ sig { returns Integer }
75
+ def total_books
76
+ @books_by_slug.size
77
+ end
78
+
79
+ sig { returns Integer }
80
+ def finished_percent
81
+ @finished_percent ||= (total_finished.to_f / total_books * 100).round
82
+ end
83
+
84
+ sig { returns String }
85
+ def finished_book_units
86
+ total_finished == 1 ? "book" : "books"
87
+ end
88
+
89
+ sig { returns String }
90
+ def book_units
91
+ total_books == 1 ? "book" : "books"
92
+ end
93
+
94
+ sig { params(books_db: Database::GoodreadsBooks).returns(T::Boolean) }
95
+ def load_from_database(books_db)
96
+ puts "#{Util::INFO_EMOJI} Loading cached Goodreads library..."
97
+
98
+ rows = books_db.find_all
99
+ rows.each { |row| add_book(Book.new(row)) }
100
+
101
+ true
102
+ end
103
+
104
+ sig { params(sync_times_db: Database::SyncTimes).void }
105
+ def update_sync_time(sync_times_db)
106
+ puts "#{Util::SAVE_EMOJI} Updating time Goodreads library was last cached..."
107
+ sync_times_db.touch(SYNC_TIME_KEY)
108
+ end
109
+
110
+ sig { params(limit: Integer, stylize: T::Boolean).returns(String) }
111
+ def to_s(limit: 5, stylize: false)
112
+ [
113
+ "📚 Found #{total_books} #{book_units} on Goodreads",
114
+ finished_books_summary(limit: limit, stylize: stylize),
115
+ ].compact.join("\n")
116
+ end
117
+
118
+ sig { params(limit: Integer, stylize: T::Boolean).returns(String) }
119
+ def finished_books_summary(limit: 5, stylize: false)
120
+ lines = T.let([
121
+ "#{Util::DONE_EMOJI} #{total_finished} #{finished_book_units} " \
122
+ "(#{finished_percent}%) in Goodreads library have been finished:",
123
+ ], T::Array[String])
124
+ lines.concat(finished_books.take(limit).map { |book| book.to_s(indent_level: 1, stylize: stylize) })
125
+ lines << "#{Util::TAB}..." if total_finished > limit
126
+ lines << ""
127
+ lines.join("\n")
128
+ end
129
+
130
+ sig { returns String }
131
+ def to_json
132
+ JSON.pretty_generate(books.map(&:to_h))
133
+ end
134
+
135
+ sig { params(isbn: String).returns(T.nilable(Book)) }
136
+ def find_by_isbn(isbn)
137
+ books.detect { |book| book.isbn == isbn }
138
+ end
139
+
140
+ private
141
+
142
+ sig { void }
143
+ def calculate_finished_unfinished_books
144
+ return if @finished_books && @unfinished_books
145
+ @finished_books, @unfinished_books = books.partition(&:finished?)
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodAudibleStorySync
4
+ module Goodreads
5
+ end
6
+ end
7
+
8
+ require_relative "goodreads/auth"
9
+ require_relative "goodreads/auth_flow"
10
+ require_relative "goodreads/book"
11
+ require_relative "goodreads/client"
12
+ require_relative "goodreads/library"