lexdrill 0.13.0 → 0.14.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a20cfbe4b7db3dbc41bec9f5e6bac7954660f81c5396bd39337b620f78038cfd
4
- data.tar.gz: 6a26fc1605e888f21569d4109f774d85d753e6e17f6e0a40b220d071cda5549c
3
+ metadata.gz: 12866aced5d028c5f5951d0a201a7351090cc27adb617c73e317b80df3dcaa0f
4
+ data.tar.gz: 2e14d88608088fdbb6bd1a0071171696182deb828b840826bd3e7a7964f1f887
5
5
  SHA512:
6
- metadata.gz: 664c053ae57b8f89b78364699dc865067e461086145c8be708e84473df83642966751bdec82c42e977f185dba6ca3799ae8aa65faa2ddd4e34f27245054a79ed
7
- data.tar.gz: 6d3c8d58b6b1b198857287546c215e519b023d051020bd4980ffe1958a7103bdc1d2167811f28aa1db42d8b93233b85923137b5f39e107c4224042c60fdce7c4
6
+ metadata.gz: 8c8947fc580a75406e2a02b164092d49dc0bf66663ed55f0b5d395374e9f169b6bb330e84cdebedbb0ea91f5af88334c8bfa5f0532699b122b9d29d01eae7d50
7
+ data.tar.gz: 1b7c635562627735a1f85bc1656398f8782bcfaead000c24be594ade643748e89b2e0993215c853139fce1c3587758640c5b73e43e11b6de9293aedcb85fcf59
data/README.md CHANGED
@@ -180,6 +180,57 @@ longer comes up automatically). If every word in the list has been
180
180
  mastered, `next` reports that on stderr and exits 1 instead of showing
181
181
  anything.
182
182
 
183
+ ### Google Sheets export
184
+
185
+ `drill export` mirrors your word list (text only, no stats) into a tab of a
186
+ Google Sheet you own, using **your own Google account** — not a service
187
+ account key. That distinction matters: a service-account private key would
188
+ have to be embedded in this published gem, meaning anyone who `gem install`s
189
+ lexdrill would get it too. Instead, `drill` uses the OAuth 2.0 **Device
190
+ Authorization Grant** ("visit this URL, enter this code," the same style of
191
+ flow `gcloud auth login` uses) — you approve access to your own account
192
+ once, and the resulting token is cached locally at
193
+ `~/.drill.gcp-token.json` (mode `0600`), never published or shared.
194
+
195
+ The OAuth client id/secret embedded in `lib/lexdrill/google_auth.rb` are
196
+ **not** secret for this use — Google's own docs say client credentials for
197
+ "TVs and Limited Input devices" / installed-app clients aren't treated as
198
+ confidential. The actual secret is your personal refresh token, which is
199
+ generated only after you interactively approve, and stays on your machine.
200
+
201
+ #### One-time Google Cloud setup (you do this yourself)
202
+
203
+ 1. Go to <https://console.cloud.google.com>, create or select a project.
204
+ 2. **APIs & Services → Library** → search "Google Sheets API" → Enable.
205
+ 3. **APIs & Services → OAuth consent screen**: User type **External**;
206
+ publishing status **Testing** is fine — add your own Google account
207
+ under "Test users".
208
+ 4. **APIs & Services → Credentials → Create Credentials → OAuth client
209
+ ID** → Application type **"TVs and Limited Input devices"** (no redirect
210
+ URI needed). Copy the generated Client ID and Client secret into
211
+ `CLIENT_ID`/`CLIENT_SECRET` in `lib/lexdrill/google_auth.rb`.
212
+ 5. "Testing" publishing status has historically imposed a 7-day
213
+ refresh-token expiry for some scopes. If `drill export` starts asking
214
+ you to re-authorize every week, switch the consent screen to **"In
215
+ production"** — for the `spreadsheets` scope (not a "restricted" scope)
216
+ this just adds an "unverified app" click-through on first consent, no
217
+ Google review required.
218
+
219
+ #### Using it
220
+
221
+ ```bash
222
+ drill remote 'https://docs.google.com/spreadsheets/d/1opBP4APL5SUvepm9qwjIYRNtDZdoY1Ee87F5PWdxaMg/edit?usp=sharing'
223
+ drill export Sheet1
224
+ ```
225
+
226
+ The first `export` prints a URL and a short code — visit it, sign in with
227
+ the Google account that has edit access to that spreadsheet, and approve.
228
+ Every export after that is silent (the cached token refreshes itself
229
+ automatically). If `<sheet-name>` doesn't exist yet as a tab in the
230
+ spreadsheet, `export` creates it; either way it always **overwrites** the
231
+ tab's contents with the current word list text (one word/phrase per row, no
232
+ stats), so it stays an exact mirror even if the list shrinks.
233
+
183
234
  ### Commands
184
235
 
185
236
  | Command | What it does |
@@ -197,3 +248,5 @@ anything.
197
248
  | `drill stats` | Print all items as `<count>\t<phrase>` (tab-separated), sorted by show count, highest first |
198
249
  | `drill rand <n>` | `drill next` shows a word ~1-in-`n` times (`n=1` is every time, the default) |
199
250
  | `drill go <number>` | Jump so the next `drill next` shows item `<number>` (1-based, see `drill list`) — prints nothing itself; refuses a graduated item; has no effect while `drill beat rand` is active, since that mode ignores the counter entirely |
251
+ | `drill remote <url>` | Set the Google Sheet to export to (global, parses the spreadsheet id out of a normal share URL) |
252
+ | `drill export <sheet-name>` | Export the word list text to the named tab (created if it doesn't exist), overwriting it; first run triggers a one-time Google device-flow sign-in |
data/lib/lexdrill/cli.rb CHANGED
@@ -17,7 +17,9 @@ class Lexdrill::CLI
17
17
  run_open: %w[open],
18
18
  run_stats: %w[stats],
19
19
  run_rand: %w[rand],
20
- run_go: %w[go]
20
+ run_go: %w[go],
21
+ run_remote: %w[remote],
22
+ run_export: %w[export]
21
23
  }.freeze
22
24
 
23
25
  def self.start(argv = ARGV)
@@ -69,6 +71,9 @@ class Lexdrill::CLI
69
71
  drill stats Print items as <count>\t<phrase>, tab-separated, highest count first
70
72
  drill rand <n> drill next shows a word ~1-in-n times (n=1 is every time)
71
73
  drill go <number> Jump so the next `next` shows item <number> (1-based, see drill list)
74
+ drill remote <url> Set the Google Sheet to export to (global, ~/.drill.remote)
75
+ drill export <sheet-name> Export the word list text to the given tab (overwrites its
76
+ contents); first run opens a one-time Google device-flow sign-in
72
77
  HELP
73
78
  0
74
79
  end
@@ -285,6 +290,77 @@ class Lexdrill::CLI
285
290
  1
286
291
  end
287
292
 
293
+ def run_remote
294
+ url = argv[1]
295
+ return print_remote_usage unless url
296
+
297
+ Lexdrill::Remote.set(url)
298
+ puts "remote spreadsheet set: #{Lexdrill::Remote.spreadsheet_id}"
299
+ 0
300
+ rescue ArgumentError
301
+ print_invalid_remote_url(url)
302
+ end
303
+
304
+ def print_remote_usage
305
+ warn "usage: drill remote <google-sheets-url>"
306
+ 1
307
+ end
308
+
309
+ def print_invalid_remote_url(url)
310
+ warn "drill: could not find a spreadsheet id in #{url.inspect}"
311
+ 1
312
+ end
313
+
314
+ def run_export
315
+ sheet_name = argv[1]
316
+ return print_export_usage unless sheet_name
317
+ return print_no_remote unless Lexdrill::Remote.configured?
318
+
319
+ perform_export(sheet_name)
320
+ rescue Lexdrill::GoogleAuth::AuthError => error
321
+ warn "drill: #{error.message}"
322
+ 1
323
+ rescue Lexdrill::SheetsClient::ApiError => error
324
+ print_sheets_api_error(error, sheet_name)
325
+ rescue Lexdrill::HTTPClient::NetworkError => error
326
+ warn "drill: network error talking to Google (#{error.message})"
327
+ 1
328
+ end
329
+
330
+ def perform_export(sheet_name)
331
+ token = Lexdrill::GoogleAuth.ensure_token!
332
+ rows = export_rows
333
+ Lexdrill::SheetsClient.overwrite_sheet(Lexdrill::Remote.spreadsheet_id, sheet_name, rows, token)
334
+ puts "exported #{rows.size} word(s) to #{sheet_name.inspect}"
335
+ 0
336
+ end
337
+
338
+ def export_rows
339
+ Lexdrill::WordList.words.map { |word| [word] }
340
+ end
341
+
342
+ def print_export_usage
343
+ warn "usage: drill export <sheet-name>"
344
+ 1
345
+ end
346
+
347
+ def print_no_remote
348
+ warn "drill: no remote spreadsheet configured; run `drill remote <url>` first"
349
+ 1
350
+ end
351
+
352
+ def print_sheets_api_error(error, sheet_name)
353
+ status = error.status
354
+ message = error.message
355
+ case status
356
+ when 404 then warn "drill: spreadsheet not found or not accessible with this Google account (check `drill remote`)"
357
+ when 403 then warn "drill: access denied — the signed-in Google account needs edit access to the spreadsheet"
358
+ when 400 then warn "drill: #{message} (is #{sheet_name.inspect} a real tab in the spreadsheet?)"
359
+ else warn "drill: Google Sheets API error (#{status}): #{message}"
360
+ end
361
+ 1
362
+ end
363
+
288
364
  def print_unknown_command(command)
289
365
  warn "drill: unknown command #{command.inspect}"
290
366
  print_help
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ # Handles the Google OAuth 2.0 Device Authorization Grant ("visit this URL,
7
+ # enter this code") so `drill export` can write to a Sheet as the user's own
8
+ # Google account, without ever needing a service account key.
9
+ #
10
+ # CLIENT_ID/CLIENT_SECRET below are NOT confidential for this OAuth client
11
+ # type ("TVs and Limited Input devices" / installed-app device flow) — see
12
+ # README's "Google Sheets export" section for why it's safe to ship these in
13
+ # published gem source. The actual secret is the refresh_token persisted at
14
+ # PATH below, obtained only after this specific user's own interactive
15
+ # consent, which never leaves this machine and is never published.
16
+ module Lexdrill::GoogleAuth
17
+ PATH = File.join(Dir.home, ".drill.gcp-token.json")
18
+
19
+ CLIENT_ID = "608056429593-ncdgh4bnfpuf9486l2rlt7g763h1gl6g.apps.googleusercontent.com"
20
+ CLIENT_SECRET = "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"
21
+
22
+ SCOPE = "https://www.googleapis.com/auth/spreadsheets"
23
+ DEVICE_CODE_URL = "https://oauth2.googleapis.com/device/code"
24
+ TOKEN_URL = "https://oauth2.googleapis.com/token"
25
+ GRANT_TYPE_DEVICE = "urn:ietf:params:oauth:grant-type:device_code"
26
+ DEFAULT_POLL_INTERVAL = 5
27
+ DEFAULT_EXPIRES_IN = 1800
28
+ EXPIRY_SKEW = 60 # seconds of safety margin before treating a token as expired
29
+
30
+ class AuthError < StandardError; end
31
+
32
+ def self.ensure_token!
33
+ return login! unless configured?
34
+
35
+ data = load_token
36
+ return data["access_token"] if Time.now.to_i < data["expires_at"].to_i
37
+
38
+ refresh!
39
+ end
40
+
41
+ def self.configured?
42
+ File.exist?(PATH)
43
+ end
44
+
45
+ def self.clear!
46
+ FileUtils.rm_f(PATH)
47
+ end
48
+
49
+ def self.login!
50
+ device = request_device_code
51
+ print_instructions(device)
52
+ poll_for_token(device)
53
+ end
54
+
55
+ def self.request_device_code
56
+ response = Lexdrill::HTTPClient.post_form(DEVICE_CODE_URL, { "client_id" => CLIENT_ID, "scope" => SCOPE })
57
+ JSON.parse(response.body)
58
+ end
59
+ private_class_method :request_device_code
60
+
61
+ def self.print_instructions(device)
62
+ puts "To let lexdrill export to Google Sheets, visit:"
63
+ puts " #{device['verification_url']}"
64
+ puts "and enter this code: #{device['user_code']}"
65
+ puts "Waiting for you to approve..."
66
+ end
67
+ private_class_method :print_instructions
68
+
69
+ def self.poll_for_token(device)
70
+ interval = (device["interval"] || DEFAULT_POLL_INTERVAL).to_i
71
+ deadline = Time.now.to_i + (device["expires_in"] || DEFAULT_EXPIRES_IN).to_i
72
+ loop do
73
+ check_not_expired(deadline)
74
+ sleep(interval)
75
+ outcome = classify_poll_result(attempt_token_exchange(device["device_code"]))
76
+ return outcome[:token] if outcome[:done]
77
+
78
+ interval += 5 if outcome[:slow_down]
79
+ end
80
+ end
81
+ private_class_method :poll_for_token
82
+
83
+ def self.check_not_expired(deadline)
84
+ return if Time.now.to_i <= deadline
85
+
86
+ raise AuthError, "device code expired before authorization completed; run the command again"
87
+ end
88
+ private_class_method :check_not_expired
89
+
90
+ def self.classify_poll_result(result)
91
+ return { done: true, token: persist_token(result) } if result["access_token"]
92
+
93
+ error = result["error"]
94
+ return { done: false } if error == "authorization_pending"
95
+ return { done: false, slow_down: true } if error == "slow_down"
96
+
97
+ raise AuthError, "Google authorization failed: #{error}"
98
+ end
99
+ private_class_method :classify_poll_result
100
+
101
+ def self.attempt_token_exchange(device_code)
102
+ response = Lexdrill::HTTPClient.post_form(TOKEN_URL, {
103
+ "client_id" => CLIENT_ID, "client_secret" => CLIENT_SECRET,
104
+ "device_code" => device_code, "grant_type" => GRANT_TYPE_DEVICE
105
+ })
106
+ JSON.parse(response.body)
107
+ end
108
+ private_class_method :attempt_token_exchange
109
+
110
+ def self.refresh!
111
+ data = load_token
112
+ params = {
113
+ "client_id" => CLIENT_ID, "client_secret" => CLIENT_SECRET,
114
+ "refresh_token" => data["refresh_token"], "grant_type" => "refresh_token"
115
+ }
116
+ handle_refresh_response(Lexdrill::HTTPClient.post_form(TOKEN_URL, params), data)
117
+ end
118
+
119
+ def self.handle_refresh_response(response, data)
120
+ body = JSON.parse(response.body)
121
+ raise_invalid_grant(body) if response.code == 400 && body["error"] == "invalid_grant"
122
+
123
+ data["access_token"] = body["access_token"]
124
+ data["expires_at"] = Time.now.to_i + body["expires_in"].to_i - EXPIRY_SKEW
125
+ save(data)
126
+ data["access_token"]
127
+ end
128
+ private_class_method :handle_refresh_response
129
+
130
+ def self.raise_invalid_grant(body)
131
+ clear!
132
+ raise AuthError, "Google access was revoked or expired (#{body['error_description']}); " \
133
+ "run `drill export` again to re-authorize"
134
+ end
135
+ private_class_method :raise_invalid_grant
136
+
137
+ def self.persist_token(data)
138
+ token = {
139
+ "access_token" => data["access_token"],
140
+ "refresh_token" => data["refresh_token"],
141
+ "expires_at" => Time.now.to_i + data["expires_in"].to_i - EXPIRY_SKEW
142
+ }
143
+ save(token)
144
+ token["access_token"]
145
+ end
146
+ private_class_method :persist_token
147
+
148
+ def self.load_token
149
+ JSON.parse(File.read(PATH))
150
+ end
151
+ private_class_method :load_token
152
+
153
+ def self.save(token)
154
+ File.write(PATH, JSON.generate(token))
155
+ File.chmod(0o600, PATH) # the ONE lexdrill config file holding a real secret
156
+ end
157
+ private_class_method :save
158
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ # The only place lexdrill talks to Net::HTTP directly. Lexdrill::GoogleAuth
8
+ # (form-encoded OAuth endpoints) and Lexdrill::SheetsClient (JSON REST) both
9
+ # go through here, so every low-level connectivity failure (DNS, timeout,
10
+ # TLS, connection refused) is normalized to a single NetworkError for
11
+ # callers to handle, instead of each caller needing to know every possible
12
+ # Net::HTTP/OpenSSL/socket exception class.
13
+ module Lexdrill::HTTPClient
14
+ OPEN_TIMEOUT = 10
15
+ READ_TIMEOUT = 15
16
+
17
+ Response = Struct.new(:code, :body)
18
+
19
+ class NetworkError < StandardError; end
20
+
21
+ def self.post_form(url, params)
22
+ uri = URI(url)
23
+ perform(uri) { |http| http.post(uri.path, URI.encode_www_form(params)) }
24
+ end
25
+
26
+ def self.json_post(url, body:, headers: {})
27
+ json_request(Net::HTTP::Post, url, body, headers)
28
+ end
29
+
30
+ def self.json_put(url, body:, headers: {})
31
+ json_request(Net::HTTP::Put, url, body, headers)
32
+ end
33
+
34
+ def self.json_get(url, headers: {})
35
+ uri = URI(url)
36
+ request = build_get_request(uri, headers)
37
+ perform(uri) { |http| http.request(request) }
38
+ end
39
+
40
+ def self.build_get_request(uri, headers)
41
+ request = Net::HTTP::Get.new(uri.request_uri)
42
+ headers.each { |key, value| request[key] = value }
43
+ request
44
+ end
45
+ private_class_method :build_get_request
46
+
47
+ def self.json_request(method_class, url, body, headers)
48
+ uri = URI(url)
49
+ request = build_json_request(method_class, uri, body, headers)
50
+ perform(uri) { |http| http.request(request) }
51
+ end
52
+ private_class_method :json_request
53
+
54
+ def self.build_json_request(method_class, uri, body, headers)
55
+ request = method_class.new(uri.request_uri)
56
+ headers.each { |key, value| request[key] = value }
57
+ request["Content-Type"] = "application/json"
58
+ request.body = JSON.generate(body)
59
+ request
60
+ end
61
+ private_class_method :build_json_request
62
+
63
+ def self.perform(uri, &block)
64
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
65
+ open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT, &block)
66
+ Response.new(response.code.to_i, response.body)
67
+ rescue StandardError => error
68
+ raise NetworkError, error.message
69
+ end
70
+ private_class_method :perform
71
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Global "which Google Sheet to export to" setting. Lives at ~/.drill.remote
4
+ # as a plain spreadsheet id, parsed out of a normal Google Sheets share URL.
5
+ module Lexdrill::Remote
6
+ PATH = File.join(Dir.home, ".drill.remote")
7
+ URL_PATTERN = %r{/spreadsheets/d/([a-zA-Z0-9_-]+)}
8
+
9
+ def self.set(url)
10
+ id = extract_id(url)
11
+ raise ArgumentError, "no spreadsheet id found in #{url.inspect}" unless id
12
+
13
+ File.write(PATH, id)
14
+ end
15
+
16
+ def self.extract_id(url)
17
+ url[URL_PATTERN, 1]
18
+ end
19
+
20
+ def self.configured?
21
+ File.exist?(PATH)
22
+ end
23
+
24
+ def self.spreadsheet_id
25
+ File.read(PATH).strip if configured?
26
+ end
27
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "json"
5
+
6
+ # Thin wrapper around the Google Sheets API v4 REST endpoints needed by
7
+ # `drill export`: create the target tab if it doesn't already exist, clear
8
+ # its contents, write new rows into it, then auto-fit column A's width to
9
+ # the content so long phrases aren't visually truncated.
10
+ module Lexdrill::SheetsClient
11
+ BASE_URL = "https://sheets.googleapis.com/v4/spreadsheets"
12
+
13
+ class ApiError < StandardError
14
+ attr_reader :status
15
+
16
+ def initialize(status, message)
17
+ @status = status
18
+ super(message)
19
+ end
20
+ end
21
+
22
+ def self.overwrite_sheet(spreadsheet_id, sheet_name, rows, access_token)
23
+ sheet_id = ensure_sheet_exists(spreadsheet_id, sheet_name, access_token)
24
+ clear(spreadsheet_id, sheet_name, access_token)
25
+ update(spreadsheet_id, sheet_name, rows, access_token)
26
+ autofit_first_column(spreadsheet_id, sheet_id, access_token)
27
+ end
28
+
29
+ def self.ensure_sheet_exists(spreadsheet_id, sheet_name, access_token)
30
+ existing = find_sheet(spreadsheet_id, sheet_name, access_token)
31
+ return existing["sheetId"] if existing
32
+
33
+ add_sheet(spreadsheet_id, sheet_name, access_token)
34
+ end
35
+ private_class_method :ensure_sheet_exists
36
+
37
+ def self.find_sheet(spreadsheet_id, sheet_name, access_token)
38
+ url = "#{BASE_URL}/#{spreadsheet_id}?fields=sheets.properties"
39
+ data = handle_response(Lexdrill::HTTPClient.json_get(url, headers: auth_header(access_token)))
40
+ data.fetch("sheets", []).map { |sheet| sheet["properties"] }.find { |props| props["title"] == sheet_name }
41
+ end
42
+ private_class_method :find_sheet
43
+
44
+ def self.add_sheet(spreadsheet_id, sheet_name, access_token)
45
+ url = "#{BASE_URL}/#{spreadsheet_id}:batchUpdate"
46
+ body = { "requests" => [{ "addSheet" => { "properties" => { "title" => sheet_name } } }] }
47
+ result = handle_response(Lexdrill::HTTPClient.json_post(url, body: body, headers: auth_header(access_token)))
48
+ result.dig("replies", 0, "addSheet", "properties", "sheetId")
49
+ end
50
+ private_class_method :add_sheet
51
+
52
+ def self.autofit_first_column(spreadsheet_id, sheet_id, access_token)
53
+ url = "#{BASE_URL}/#{spreadsheet_id}:batchUpdate"
54
+ dimensions = { "sheetId" => sheet_id, "dimension" => "COLUMNS", "startIndex" => 0, "endIndex" => 1 }
55
+ body = { "requests" => [{ "autoResizeDimensions" => { "dimensions" => dimensions } }] }
56
+ handle_response(Lexdrill::HTTPClient.json_post(url, body: body, headers: auth_header(access_token)))
57
+ end
58
+ private_class_method :autofit_first_column
59
+
60
+ def self.clear(spreadsheet_id, sheet_name, access_token)
61
+ url = "#{BASE_URL}/#{spreadsheet_id}/values/#{encoded_range(sheet_name)}:clear"
62
+ handle_response(Lexdrill::HTTPClient.json_post(url, body: {}, headers: auth_header(access_token)))
63
+ end
64
+
65
+ def self.update(spreadsheet_id, sheet_name, rows, access_token)
66
+ url = "#{BASE_URL}/#{spreadsheet_id}/values/#{encoded_range(sheet_name)}?valueInputOption=RAW"
67
+ body = { "range" => quoted_range(sheet_name), "majorDimension" => "ROWS", "values" => rows }
68
+ handle_response(Lexdrill::HTTPClient.json_put(url, body: body, headers: auth_header(access_token)))
69
+ end
70
+
71
+ def self.auth_header(token)
72
+ { "Authorization" => "Bearer #{token}" }
73
+ end
74
+ private_class_method :auth_header
75
+
76
+ # Quoting the sheet name defends against tab names containing spaces;
77
+ # harmless for plain names too. The URL path and the request body's
78
+ # "range" field must match exactly (Google rejects the request otherwise),
79
+ # so both go through this same quoting.
80
+ def self.quoted_range(sheet_name)
81
+ "'#{sheet_name}'"
82
+ end
83
+ private_class_method :quoted_range
84
+
85
+ def self.encoded_range(sheet_name)
86
+ URI.encode_www_form_component(quoted_range(sheet_name))
87
+ end
88
+ private_class_method :encoded_range
89
+
90
+ def self.handle_response(response)
91
+ status = response.code
92
+ return JSON.parse(response.body) if status == 200
93
+
94
+ raise ApiError.new(status, error_message(response))
95
+ end
96
+ private_class_method :handle_response
97
+
98
+ def self.error_message(response)
99
+ status = response.code
100
+ JSON.parse(response.body).dig("error", "message") || "HTTP #{status}"
101
+ rescue JSON::ParserError
102
+ "HTTP #{status}"
103
+ end
104
+ private_class_method :error_message
105
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lexdrill
4
- VERSION = "0.13.0"
4
+ VERSION = "0.14.0"
5
5
  end
data/lib/lexdrill.rb CHANGED
@@ -9,6 +9,10 @@ require_relative "lexdrill/stats"
9
9
  require_relative "lexdrill/word_list"
10
10
  require_relative "lexdrill/toggle"
11
11
  require_relative "lexdrill/rand"
12
+ require_relative "lexdrill/remote"
13
+ require_relative "lexdrill/http_client"
14
+ require_relative "lexdrill/google_auth"
15
+ require_relative "lexdrill/sheets_client"
12
16
  require_relative "lexdrill/shell_snippet"
13
17
  require_relative "lexdrill/inspector"
14
18
  require_relative "lexdrill/colorizer"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lexdrill
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Siarhei Kisliak
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-10 00:00:00.000000000 Z
11
+ date: 2026-07-11 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: lexdrill prints a vocabulary word or phrase on demand, tracking how often
14
14
  each one has been shown.
@@ -32,9 +32,13 @@ files:
32
32
  - lib/lexdrill/counter.rb
33
33
  - lib/lexdrill/default_words.rb
34
34
  - lib/lexdrill/format.rb
35
+ - lib/lexdrill/google_auth.rb
36
+ - lib/lexdrill/http_client.rb
35
37
  - lib/lexdrill/inspector.rb
36
38
  - lib/lexdrill/line_formatter.rb
37
39
  - lib/lexdrill/rand.rb
40
+ - lib/lexdrill/remote.rb
41
+ - lib/lexdrill/sheets_client.rb
38
42
  - lib/lexdrill/shell_snippet.rb
39
43
  - lib/lexdrill/stats.rb
40
44
  - lib/lexdrill/toggle.rb