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,213 @@
1
+ # frozen_string_literal: true
2
+ # encoding: utf-8
3
+ # typed: true
4
+
5
+ require "date"
6
+ require "rainbow"
7
+
8
+ module GoodAudibleStorySync
9
+ module Audible
10
+ class LibraryItem
11
+ extend T::Sig
12
+
13
+ sig { params(finished_at: T.nilable(DateTime)).void }
14
+ attr_writer :finished_at
15
+
16
+ sig { returns Hash }
17
+ attr_reader :data
18
+
19
+ sig { params(data: Hash).void }
20
+ def initialize(data)
21
+ @data = data
22
+ end
23
+
24
+ sig { returns T.nilable(DateTime) }
25
+ def finished_at
26
+ return @finished_at if defined?(@finished_at)
27
+ finished_at_str = @data["finished_at"]
28
+ if finished_at_str.nil? && @data.dig("listening_status", "is_finished")
29
+ finished_at_str = @data.dig("listening_status", "finished_at_timestamp")
30
+ end
31
+ @finished_at = finished_at_str ? DateTime.parse(finished_at_str) : nil
32
+ rescue Date::Error
33
+ nil
34
+ end
35
+
36
+ sig { returns T.nilable(DateTime) }
37
+ def last_listened_at
38
+ return @last_listened_at if defined?(@last_listened_at)
39
+ date_str = @data.dig("listening_status", "finished_at_timestamp")
40
+ @last_listened_at = date_str ? DateTime.parse(date_str) : nil
41
+ rescue Date::Error
42
+ nil
43
+ end
44
+
45
+ sig { returns T.nilable(DateTime) }
46
+ def added_to_library_at
47
+ return @added_to_library_at if defined?(@added_to_library_at)
48
+ date_added_str = @data.dig("library_status", "date_added")
49
+ @added_to_library_at = date_added_str ? DateTime.parse(date_added_str) : purchase_date
50
+ rescue Date::Error
51
+ nil
52
+ end
53
+
54
+ sig { returns T.nilable(String) }
55
+ def asin
56
+ @data["asin"]
57
+ end
58
+
59
+ sig { returns T::Array[String] }
60
+ def narrators
61
+ return @narrators if @narrators
62
+ narrator_str = T.let(@data["narrator"], T.nilable(String))
63
+ if narrator_str # data from database
64
+ @narrators = Util.split_words(narrator_str)
65
+ else # data from Audible API
66
+ hashes = @data["narrators"] || []
67
+ @narrators = hashes.map { |hash| hash["name"] }
68
+ end
69
+ end
70
+
71
+ sig { returns T::Array[String] }
72
+ def authors
73
+ return @authors if @authors
74
+ author_str = T.let(@data["author"], T.nilable(String))
75
+ if author_str # data from database
76
+ @authors = Util.split_words(author_str)
77
+ else # data from Audible API
78
+ hashes = @data["authors"] || []
79
+ @authors = hashes.map { |hash| hash["name"] }
80
+ end
81
+ end
82
+
83
+ sig { params(books_db: Database::AudibleBooks).returns(T::Boolean) }
84
+ def save_to_database(books_db)
85
+ isbn = self.isbn
86
+ return false unless isbn
87
+
88
+ books_db.upsert(isbn: isbn, title: title, author: Util.join_words(authors),
89
+ narrator: Util.join_words(narrators), finished_at: finished_at,
90
+ percent_complete: percent_complete)
91
+
92
+ true
93
+ end
94
+
95
+ sig { params(stylize: T::Boolean).returns(T.nilable(String)) }
96
+ def title(stylize: false)
97
+ value = @data["title"]
98
+ return value unless stylize && value
99
+ Rainbow(value).underline
100
+ end
101
+
102
+ sig { returns Integer }
103
+ def percent_complete
104
+ return @percent_complete if @percent_complete
105
+ pct = T.let(
106
+ @data["percent_complete"] || @data.dig("listening_status", "percent_complete"),
107
+ T.nilable(T.any(Float, Integer))
108
+ )
109
+ @percent_complete = pct.nil? ? 0 : pct.round
110
+ end
111
+
112
+ sig { returns T::Boolean }
113
+ def finished?
114
+ return true if finished_at || percent_complete == 100
115
+ is_finished = @data.dig("listening_status", "is_finished")
116
+ !!is_finished
117
+ end
118
+
119
+ sig { returns T::Boolean }
120
+ def started?
121
+ return @is_started if defined?(@is_started)
122
+ @is_started = percent_complete > 0 || !@data["listening_status"].nil?
123
+ end
124
+
125
+ sig { returns T.nilable(String) }
126
+ def isbn
127
+ @data["isbn"]
128
+ end
129
+
130
+ sig { returns T.nilable(Integer) }
131
+ def time_remaining_in_seconds
132
+ @data.dig("listening_status", "time_remaining_seconds")
133
+ end
134
+
135
+ sig { params(indent_level: Integer, stylize: T::Boolean).returns(String) }
136
+ def title_and_authors(indent_level: 0, stylize: false)
137
+ "#{Util::TAB * indent_level}#{title(stylize: stylize)} by #{Util.join_words(authors)}"
138
+ end
139
+
140
+ sig { params(indent_level: Integer, stylize: T::Boolean).returns(String) }
141
+ def narrator_summary(indent_level: 0, stylize: false)
142
+ value = "Narrated by #{Util.join_words(narrators)}"
143
+ value = Rainbow(value).italic if stylize
144
+ "#{Util::TAB * indent_level}#{Util::NEWLINE_EMOJI} #{value}"
145
+ end
146
+
147
+ sig { params(stylize: T::Boolean).returns(String) }
148
+ def finish_status(stylize: false)
149
+ finished_at = self.finished_at
150
+ if finished_at
151
+ value = "Finished #{Util.pretty_time(finished_at)}"
152
+ value = Rainbow(value).green if stylize
153
+ value
154
+ elsif finished?
155
+ value = "Finished"
156
+ value = Rainbow(value).green if stylize
157
+ value
158
+ elsif started?
159
+ value = "#{percent_complete}% complete"
160
+ value = Rainbow(value).yellow if stylize
161
+ value
162
+ else
163
+ value = "Not started"
164
+ value = Rainbow(value).white if stylize
165
+ value
166
+ end
167
+ end
168
+
169
+ sig { returns String }
170
+ def inspect
171
+ @data.inspect
172
+ end
173
+
174
+ sig { params(indent_level: Integer, stylize: T::Boolean).returns(String) }
175
+ def to_s(indent_level: 0, stylize: false)
176
+ line1 = "#{title_and_authors(indent_level: indent_level, stylize: stylize)} — " \
177
+ "#{finish_status(stylize: stylize)}"
178
+ line2 = narrator_summary(indent_level: indent_level + 1, stylize: stylize)
179
+ [line1, line2].join("\n")
180
+ end
181
+
182
+ sig { returns T.nilable(String) }
183
+ def search_query
184
+ return unless title
185
+ [title, Util.join_words(authors)].compact.join(" ")
186
+ end
187
+
188
+ sig { returns T.nilable(DateTime) }
189
+ def purchase_date
190
+ return @purchase_date if defined?(@purchase_date)
191
+ purchase_date_str = @data["purchase_date"]
192
+ @purchase_date = purchase_date_str ? DateTime.parse(purchase_date_str) : nil
193
+ rescue Date::Error
194
+ nil
195
+ end
196
+
197
+ # Public: Get a rough idea of when the item would have been listened to. The start date will be too broad, as
198
+ # it's just when the item was added to the library and not necessarily when it was begun.
199
+ sig { returns T::Range[T.nilable(DateTime)] }
200
+ def listening_time_range
201
+ @listening_time_range ||= Range.new(added_to_library_at, last_listened_at)
202
+ end
203
+
204
+ sig { returns T::Hash[Symbol, T.untyped] }
205
+ def to_h
206
+ @to_h ||= @data.reject { |k, v| v.nil? }.map { |k, v| [k.to_sym, v] }.to_h.merge(
207
+ finished_at: finished_at&.iso8601,
208
+ purchase_date: purchase_date&.iso8601,
209
+ )
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ module GoodAudibleStorySync
5
+ module Audible
6
+ class UserProfile
7
+ extend T::Sig
8
+
9
+ sig { params(data: Hash).void }
10
+ def initialize(data)
11
+ @data = data
12
+ end
13
+
14
+ sig { returns T.nilable(String) }
15
+ def user_id
16
+ @data["user_id"]
17
+ end
18
+
19
+ sig { returns T.nilable(String) }
20
+ def name
21
+ @data["name"]
22
+ end
23
+
24
+ sig { returns T.nilable(String) }
25
+ def email
26
+ @data["email"]
27
+ end
28
+
29
+ sig { params(indent_level: Integer).returns(String) }
30
+ def to_s(indent_level: 0)
31
+ tab = Util::TAB * indent_level
32
+ line1 = "#{tab}Audible user ID: #{user_id}"
33
+ line2 = "#{tab}Name: #{name}"
34
+ line3 = "#{tab}Email: #{email}"
35
+ [line1, line2, line3].join("\n")
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodAudibleStorySync
4
+ module Audible
5
+ end
6
+ end
7
+
8
+ require_relative "audible/auth"
9
+ require_relative "audible/auth_flow"
10
+ require_relative "audible/client"
11
+ require_relative "audible/library"
12
+ require_relative "audible/library_item"
13
+ require_relative "audible/user_profile"
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ module GoodAudibleStorySync
5
+ module Database
6
+ class AudibleBooks
7
+ extend T::Sig
8
+
9
+ TABLE_NAME = "audible_books"
10
+
11
+ sig { params(db: SQLite3::Database).void }
12
+ def initialize(db:)
13
+ @db = db
14
+ end
15
+
16
+ sig { void }
17
+ def create_table
18
+ puts "#{Util::INFO_EMOJI} Ensuring table #{TABLE_NAME} exists..."
19
+ @db.execute <<~SQL
20
+ CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
21
+ isbn TEXT PRIMARY KEY,
22
+ title TEXT,
23
+ author TEXT,
24
+ narrator TEXT,
25
+ finished_at TEXT
26
+ );
27
+ SQL
28
+ unless Client.column_exists?(db: @db, table_name: TABLE_NAME, column_name: "percent_complete")
29
+ puts "#{Util::INFO_EMOJI} Adding percent_complete column to #{TABLE_NAME}..."
30
+ @db.execute("ALTER TABLE #{TABLE_NAME} ADD COLUMN percent_complete INTEGER")
31
+ end
32
+ end
33
+
34
+ sig { returns T::Array[T::Hash[String, T.untyped]] }
35
+ def find_all
36
+ @db.execute("SELECT isbn, title, author, narrator, finished_at, percent_complete " \
37
+ "FROM #{TABLE_NAME} ORDER BY finished_at DESC, percent_complete DESC, title ASC, isbn ASC")
38
+ end
39
+
40
+ sig do
41
+ params(
42
+ isbn: String,
43
+ title: T.nilable(String),
44
+ author: T.nilable(String),
45
+ narrator: T.nilable(String),
46
+ finished_at: T.nilable(T.any(String, DateTime, Time, Date)),
47
+ percent_complete: Integer
48
+ ).void
49
+ end
50
+ def upsert(isbn:, title:, author:, narrator:, finished_at:, percent_complete:)
51
+ puts "#{Util::INFO_EMOJI} Saving Audible book #{isbn}..."
52
+ finished_at_str = if finished_at.respond_to?(:iso8601)
53
+ T.unsafe(finished_at).iso8601
54
+ else
55
+ finished_at
56
+ end
57
+ values = [isbn, title, author, narrator, finished_at_str, percent_complete]
58
+ @db.execute(
59
+ "INSERT INTO #{TABLE_NAME} (isbn, title, author, narrator, finished_at, percent_complete) " \
60
+ "VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(isbn) DO UPDATE SET title=excluded.title, " \
61
+ "author=excluded.author, narrator=excluded.narrator, " \
62
+ "finished_at=excluded.finished_at, percent_complete=excluded.percent_complete", values)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ module GoodAudibleStorySync
5
+ module Database
6
+ class Client
7
+ extend T::Sig
8
+
9
+ DEFAULT_DATABASE_FILE = "good_audible_story_sync.db"
10
+
11
+ sig { params(db_file: String, cipher: T.nilable(Util::Cipher)).returns(Client) }
12
+ def self.load(db_file = DEFAULT_DATABASE_FILE, cipher: nil)
13
+ client = new(db_file: db_file, cipher: cipher)
14
+ client.create_tables
15
+ client
16
+ end
17
+
18
+ sig do
19
+ params(db: SQLite3::Database, table_name: String, column_name: String).returns(T::Boolean)
20
+ end
21
+ def self.column_exists?(db:, table_name:, column_name:)
22
+ result = db.get_first_value("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
23
+ table_name, column_name)
24
+ result == 1
25
+ end
26
+
27
+ sig { returns SQLite3::Database }
28
+ attr_reader :db
29
+
30
+ sig { returns Util::Cipher }
31
+ attr_reader :cipher
32
+
33
+ sig { returns Credentials }
34
+ attr_reader :credentials
35
+
36
+ sig { returns AudibleBooks }
37
+ attr_reader :audible_books
38
+
39
+ sig { returns GoodreadsBooks }
40
+ attr_reader :goodreads_books
41
+
42
+ sig { returns StorygraphBooks }
43
+ attr_reader :storygraph_books
44
+
45
+ sig { returns SyncTimes }
46
+ attr_reader :sync_times
47
+
48
+ sig { params(db_file: String, cipher: T.nilable(Util::Cipher)).void }
49
+ def initialize(db_file: DEFAULT_DATABASE_FILE, cipher: nil)
50
+ @db = SQLite3::Database.new(db_file)
51
+ @db.results_as_hash = true
52
+ @cipher = cipher || Util::Cipher.new
53
+ @credentials = Credentials.new(db_client: self)
54
+ @audible_books = AudibleBooks.new(db: @db)
55
+ @goodreads_books = GoodreadsBooks.new(db: @db)
56
+ @storygraph_books = StorygraphBooks.new(db: @db)
57
+ @sync_times = SyncTimes.new(db: @db)
58
+ end
59
+
60
+ sig { void }
61
+ def create_tables
62
+ @audible_books.create_table
63
+ @storygraph_books.create_table
64
+ @goodreads_books.create_table
65
+ @credentials.create_table
66
+ @sync_times.create_table
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ module GoodAudibleStorySync
5
+ module Database
6
+ class Credentials
7
+ extend T::Sig
8
+
9
+ TABLE_NAME = "credentials"
10
+
11
+ sig { params(db_client: Database::Client).void }
12
+ def initialize(db_client:)
13
+ @db = T.let(db_client.db, SQLite3::Database)
14
+ @cipher = T.let(db_client.cipher, Util::Cipher)
15
+ end
16
+
17
+ sig { params(key: String).returns(T.nilable(T::Hash[String, T.untyped])) }
18
+ def find(key:)
19
+ puts "#{Util::INFO_EMOJI} Looking for '#{key}' credentials..."
20
+ encrypted_value = @db.get_first_value("SELECT value FROM #{TABLE_NAME} WHERE key = ?", key)
21
+ return unless encrypted_value
22
+
23
+ value = @cipher.decrypt(encrypted_value)
24
+ JSON.parse(value)
25
+ end
26
+
27
+ sig { void }
28
+ def create_table
29
+ puts "#{Util::INFO_EMOJI} Ensuring table #{TABLE_NAME} exists..."
30
+ @db.execute <<~SQL
31
+ CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
32
+ key TEXT PRIMARY KEY,
33
+ value BLOB NOT NULL
34
+ );
35
+ SQL
36
+ end
37
+
38
+ sig { params(key: String, value: T::Hash[String, T.untyped]).void }
39
+ def upsert(key:, value:)
40
+ encrypted_value = @cipher.encrypt(value.to_json)
41
+ values = [key, encrypted_value]
42
+ puts "#{Util::INFO_EMOJI} Saving '#{key}' credentials..."
43
+ @db.execute("INSERT INTO #{TABLE_NAME} (key, value) VALUES (?, ?) " \
44
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value", values)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ module GoodAudibleStorySync
5
+ module Database
6
+ class GoodreadsBooks
7
+ extend T::Sig
8
+
9
+ TABLE_NAME = "goodreads_books"
10
+
11
+ sig { params(db: SQLite3::Database).void }
12
+ def initialize(db:)
13
+ @db = db
14
+ end
15
+
16
+ sig { void }
17
+ def create_table
18
+ puts "#{Util::INFO_EMOJI} Ensuring table #{TABLE_NAME} exists..."
19
+ @db.execute <<~SQL
20
+ CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
21
+ slug TEXT PRIMARY KEY,
22
+ title TEXT,
23
+ author TEXT,
24
+ status TEXT,
25
+ isbn TEXT
26
+ );
27
+ SQL
28
+ end
29
+
30
+ sig { returns T::Array[T::Hash[String, T.untyped]] }
31
+ def find_all
32
+ @db.execute("SELECT slug, title, author, status, isbn " \
33
+ "FROM #{TABLE_NAME} ORDER BY title ASC, slug ASC")
34
+ end
35
+
36
+ sig { params(slug: String).void }
37
+ def delete(slug)
38
+ puts "#{Util::INFO_EMOJI} Removing cached Goodreads book #{slug}..."
39
+ @db.execute("DELETE FROM #{TABLE_NAME} WHERE slug = ?", slug)
40
+ end
41
+
42
+ sig do
43
+ params(
44
+ slug: String,
45
+ title: T.nilable(String),
46
+ author: T.nilable(String),
47
+ isbn: T.nilable(String),
48
+ status: T.nilable(String)
49
+ ).void
50
+ end
51
+ def upsert(slug:, title:, author:, isbn:, status:)
52
+ puts "#{Util::INFO_EMOJI} Saving Goodreads book #{slug}..."
53
+ values = [slug, title, author, isbn, status]
54
+ @db.execute("INSERT INTO #{TABLE_NAME} (slug, title, author, isbn, status) " \
55
+ "VALUES (?, ?, ?, ?, ?) ON CONFLICT(slug) DO UPDATE SET title=excluded.title, " \
56
+ "author=excluded.author, isbn=excluded.isbn, status=excluded.status", values)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ module GoodAudibleStorySync
5
+ module Database
6
+ class StorygraphBooks
7
+ extend T::Sig
8
+
9
+ TABLE_NAME = "storygraph_books"
10
+
11
+ sig { params(db: SQLite3::Database).void }
12
+ def initialize(db:)
13
+ @db = db
14
+ end
15
+
16
+ sig { void }
17
+ def create_table
18
+ puts "#{Util::INFO_EMOJI} Ensuring table #{TABLE_NAME} exists..."
19
+ @db.execute <<~SQL
20
+ CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
21
+ id TEXT PRIMARY KEY,
22
+ title TEXT,
23
+ author TEXT,
24
+ finished_on TEXT
25
+ );
26
+ SQL
27
+ unless Client.column_exists?(db: @db, table_name: TABLE_NAME, column_name: "isbn")
28
+ puts "#{Util::INFO_EMOJI} Adding isbn column to #{TABLE_NAME}..."
29
+ @db.execute("ALTER TABLE #{TABLE_NAME} ADD COLUMN isbn TEXT")
30
+ end
31
+ unless Client.column_exists?(db: @db, table_name: TABLE_NAME, column_name: "status")
32
+ puts "#{Util::INFO_EMOJI} Adding status column to #{TABLE_NAME}..."
33
+ @db.execute("ALTER TABLE #{TABLE_NAME} ADD COLUMN status TEXT")
34
+ end
35
+ end
36
+
37
+ sig { returns T::Array[T::Hash[String, T.untyped]] }
38
+ def find_all
39
+ @db.execute("SELECT id, title, author, finished_on, status " \
40
+ "FROM #{TABLE_NAME} ORDER BY finished_on DESC, title ASC, id ASC")
41
+ end
42
+
43
+ sig { params(id: String).void }
44
+ def delete(id)
45
+ puts "#{Util::INFO_EMOJI} Removing cached Storygraph book #{id}..."
46
+ @db.execute("DELETE FROM #{TABLE_NAME} WHERE id = ?", id)
47
+ end
48
+
49
+ sig do
50
+ params(
51
+ id: String,
52
+ title: T.nilable(String),
53
+ author: T.nilable(String),
54
+ finished_on: T.nilable(T.any(String, Date)),
55
+ isbn: T.nilable(String),
56
+ status: T.nilable(String)
57
+ ).void
58
+ end
59
+ def upsert(id:, title:, author:, finished_on:, isbn:, status:)
60
+ puts "#{Util::INFO_EMOJI} Saving Storygraph book #{id}..."
61
+ finished_on_str = if finished_on.respond_to?(:iso8601)
62
+ T.unsafe(finished_on).iso8601
63
+ else
64
+ finished_on
65
+ end
66
+ values = [id, title, author, finished_on_str, isbn, status]
67
+ @db.execute("INSERT INTO #{TABLE_NAME} (id, title, author, finished_on, isbn, status) " \
68
+ "VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title=excluded.title, " \
69
+ "author=excluded.author, finished_on=excluded.finished_on, isbn=excluded.isbn, " \
70
+ "status=excluded.status", values)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require "date"
5
+
6
+ module GoodAudibleStorySync
7
+ module Database
8
+ class SyncTimes
9
+ extend T::Sig
10
+
11
+ TABLE_NAME = "sync_times"
12
+
13
+ sig { params(db: SQLite3::Database).void }
14
+ def initialize(db:)
15
+ @db = db
16
+ end
17
+
18
+ sig { params(key: String).returns(T.nilable(DateTime)) }
19
+ def find(key)
20
+ puts "#{Util::INFO_EMOJI} Checking when #{key} was last synced..."
21
+ timestamp_str = @db.get_first_value("SELECT timestamp FROM #{TABLE_NAME} WHERE key = ?", key)
22
+ return unless timestamp_str
23
+
24
+ DateTime.parse(timestamp_str)
25
+ end
26
+
27
+ sig { void }
28
+ def create_table
29
+ puts "#{Util::INFO_EMOJI} Ensuring table #{TABLE_NAME} exists..."
30
+ @db.execute <<~SQL
31
+ CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
32
+ key TEXT PRIMARY KEY,
33
+ timestamp TEXT
34
+ );
35
+ SQL
36
+ end
37
+
38
+ sig { params(key: String).void }
39
+ def touch(key)
40
+ upsert(key: key, timestamp: DateTime.now)
41
+ end
42
+
43
+ sig { params(key: String, timestamp: T.nilable(DateTime)).void }
44
+ def upsert(key:, timestamp:)
45
+ puts "#{Util::INFO_EMOJI} Saving sync time #{key}..."
46
+ values = [key, timestamp&.iso8601]
47
+ @db.execute("INSERT INTO #{TABLE_NAME} (key, timestamp) " \
48
+ "VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET timestamp=excluded.timestamp", values)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require "sqlite3"
5
+
6
+ module GoodAudibleStorySync
7
+ module Database
8
+ end
9
+ end
10
+
11
+ require_relative "database/audible_books"
12
+ require_relative "database/client"
13
+ require_relative "database/credentials"
14
+ require_relative "database/goodreads_books"
15
+ require_relative "database/storygraph_books"
16
+ require_relative "database/sync_times"