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