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