gslide 0.1.2 → 0.2.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: c60628d84c354c773b26428a27bf090324f9d480e7fbaa21f61b9eff373afcfd
4
- data.tar.gz: 35dfbc790360627c4829f6747e9e25532ab0342d8044c61259e77bf050e85f2c
3
+ metadata.gz: b3f719011cb4ab8f9e07b8f03bf76609302e6ea2491d1f6dd8dad5c7534c0eed
4
+ data.tar.gz: eca34767fba2927ee5d01966c261a2251146d89922f942643f91b3ae56581cec
5
5
  SHA512:
6
- metadata.gz: 3998d8e6e183a9a7f1bc4164284a26fa4296c11187dcbf848f8dbe0f75ba6fdc5bb6b9e4407007d1cd7a11d6fd3f5c46b023b7fcda9000a23f3c2fdf0c88eddf
7
- data.tar.gz: '086712b43b87c1a09ee7563c028af143282dc22f3fd74762c0e0dd2d99c81f8d78268affa80be92b1f3eb6e5fe98700356132d8d7f97f6d59ed9e50535333228'
6
+ metadata.gz: cd49143158ee8e357dca44cfa114b5be71eee28bec81499c0d8e81ee575550b8cdf214c8a2eba2e41a41e602d7d485ce3c7032779301a897360d53be4a7cde4a
7
+ data.tar.gz: 0c0657e347e66e54d1408490bf587ac814fab41968ef071894e8aad84ae506e507ad3ce887b3f4be56e79a6f8373c68829e0c7990ffdb68dda85f9125d88d377
data/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## [0.2.0] - 2026-11-14
2
+
3
+ - Add `Gslide::Presentation.generate_object_id` and `Gslide::Presentation::OBJECT_ID_PATTERN`, for IDs of new pages and page elements
4
+ - Require `json`, so gslide works outside Rails
5
+ - Add `ForbiddenError` (403), `NotFoundError` (404), `ServerError` (5xx) and `TimeoutError`
6
+ - Raise `Gslide::ServerError` instead of `JSON::ParserError` when an error response is not JSON, such as an HTML 502 page
7
+ - Include Google's message in `Gslide::QuotaExceededError`; treat Drive's 403 rate-limit errors as quota errors
8
+ - Retry GET requests on quota errors, 5xx and timeouts (3 attempts); POST requests are not retried, except `batch_update` on quota errors as before
9
+ - Set open (10 s) and read (60 s) timeouts
10
+ - Add `Gslide::Models::Drive#trash` (a file already gone counts as done)
11
+ - Add `Gslide::Models::Drive#rename`
12
+ - Add `Gslide::Models::Drive#copy`, e.g. a template into a folder (works with the `drive.file` scope and shared drives)
13
+
14
+ ## [0.1.3] - 2026-08-19
15
+
16
+ - Add `Gslide::Presentation::PRESENTATION_ID_PATTERN` and `Gslide::Presentation.file_id_in`, so callers can validate a presentation id without repeating the pattern
17
+
1
18
  ## [0.1.2] - 2025-05-09
2
19
 
3
20
  - Add `Gslide::HTTPError`, `Gslide::UnauthorizedError` and `Gslide::QuotaExceededError` #10
data/README.md CHANGED
@@ -92,6 +92,18 @@ pres.batch_update({
92
92
  #=> true
93
93
  ```
94
94
 
95
+ Copy a template into a folder, rename a file, and trash a file. Works with the `drive.file` scope for files the user picked (for example with Google Picker) or the app created.
96
+
97
+ ```rb
98
+ drive = Gslide::Drive.new(auth: editor)
99
+ deck_id = drive.copy(template_id, name: "Report", folder_id: folder_id)
100
+ #=> "1itXwmlLbyTRa1QKhrsO16OhJPEJUK6eh2d1nqsQfuqY"
101
+ drive.rename(deck_id, name: "Monthly Report")
102
+ #=> true
103
+ drive.trash(deck_id)
104
+ #=> true (also when the file is already gone)
105
+ ```
106
+
95
107
  ## Development
96
108
 
97
109
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -1,60 +1,136 @@
1
+ require "json"
1
2
  require "net/http"
2
3
 
3
4
  module Gslide
5
+
6
+ # @private
4
7
  module Concerns
5
8
  module Requests
9
+ OPEN_TIMEOUT = 10
10
+ READ_TIMEOUT = 60
11
+
12
+ # Attempts for a GET request, including the first one.
13
+ # GET is safe to repeat, so it retries on quota errors, 5xx and timeouts.
14
+ GET_ATTEMPTS = 3
15
+
6
16
  def get_request(uri, auth_token: "")
7
- req = Net::HTTP::Get.new(uri.to_s)
8
- req['Authorization'] = "Bearer #{auth_token}"
17
+ attempts = 0
18
+ begin
19
+ attempts += 1
20
+ req = Net::HTTP::Get.new(uri.to_s)
21
+ perform_request(uri, req, auth_token)
22
+ rescue Gslide::QuotaExceededError, Gslide::ServerError, Gslide::TimeoutError
23
+ raise unless attempts < GET_ATTEMPTS
9
24
 
10
- res = Net::HTTP.start(uri.host, uri.port, :use_ssl => uri.scheme == 'https') do |http|
11
- http.request(req)
12
- end
13
- response_body = JSON(res.body)
14
-
15
- case res
16
- when Net::HTTPSuccess
17
- response_body
18
- when Net::HTTPUnauthorized
19
- msg = response_body["error"] ? response_body["error"]["message"] : "Unauthorized"
20
- raise Gslide::UnauthorizedError, msg
21
- else
22
- msg = response_body["error"] ? response_body["error"]["message"] : "HTTPError"
23
- raise Gslide::HTTPError, msg
25
+ sleep retry_delay(attempts)
26
+ retry
24
27
  end
25
28
  end
26
29
 
30
+ # POST is not retried here: a request that failed on the server may
31
+ # still have been applied. Callers retry where it is safe, as
32
+ # Presentation#batch_update does for quota errors.
27
33
  def post_request(uri, auth_token: "", body: {})
28
34
  req = Net::HTTP::Post.new(uri.to_s)
29
35
  req.initialize_http_header 'Content-Type' => 'application/json'
30
- req['Authorization'] = "Bearer #{auth_token}"
36
+ req.body = body
31
37
 
38
+ perform_request(uri, req, auth_token)
39
+ end
40
+
41
+ # PATCH is not retried either, for the same reason as POST.
42
+ def patch_request(uri, auth_token: "", body: {})
43
+ req = Net::HTTP::Patch.new(uri.to_s)
44
+ req.initialize_http_header 'Content-Type' => 'application/json'
32
45
  req.body = body
33
46
 
34
- res = Net::HTTP.start(uri.host, uri.port, :use_ssl => uri.scheme == 'https') do |http|
47
+ perform_request(uri, req, auth_token)
48
+ end
49
+
50
+ private
51
+
52
+ # Seconds to wait after failed attempt number +attempts+: 20, 50, 100...
53
+ def retry_delay(attempts)
54
+ 10 + attempts * attempts * 10
55
+ end
56
+
57
+ def perform_request(uri, req, auth_token)
58
+ req['Authorization'] = "Bearer #{auth_token}"
59
+
60
+ res = Net::HTTP.start(uri.host, uri.port,
61
+ use_ssl: uri.scheme == 'https',
62
+ open_timeout: OPEN_TIMEOUT,
63
+ read_timeout: READ_TIMEOUT) do |http|
35
64
  http.request(req)
36
65
  end
37
66
 
38
- response_body = JSON(res.body)
67
+ handle_response(res.code.to_i, res.body)
68
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
69
+ raise Gslide::TimeoutError, "#{e.class} from #{uri.host}"
70
+ end
71
+
72
+ # @param [Integer] status the HTTP status code.
73
+ # @param [String, nil] body the response body.
74
+ # @return [Hash] the parsed body of a successful response.
75
+ def handle_response(status, body)
76
+ response_body = parse_body(body)
77
+
78
+ if (200..299).cover?(status)
79
+ return response_body if response_body
80
+
81
+ raise Gslide::HTTPError, "Invalid JSON response"
82
+ end
83
+
84
+ msg = error_message(response_body)
85
+
86
+ case status
87
+ when 401
88
+ raise Gslide::UnauthorizedError, msg || "Unauthorized"
89
+ when 403
90
+ if rate_limited?(response_body)
91
+ raise Gslide::QuotaExceededError, msg || "RateLimitExceeded"
92
+ end
39
93
 
40
- case res
41
- when Net::HTTPSuccess
42
- response_body
43
- when Net::HTTPUnauthorized
44
- msg = response_body["error"] ? response_body["error"]["message"] : "Unauthorized"
45
- raise Gslide::UnauthorizedError, msg
46
- when Net::HTTPTooManyRequests
94
+ raise Gslide::ForbiddenError, msg || "Forbidden"
95
+ when 404
96
+ raise Gslide::NotFoundError, msg || "NotFound"
97
+ when 429
47
98
  # "Quota exceeded for quota metric 'Write requests' and limit 'Write requests per minute per user' of service 'slides.googleapis.com' for consumer 'project_number:012345678901'."
48
- if response_body["error"] && response_body["error"]["message"] =~ /Quota exceeded/
99
+ if msg =~ /Quota exceeded/
49
100
  raise Gslide::QuotaExceededError, msg
50
101
  else
51
102
  raise Gslide::HTTPError, msg || "TooManyRequests"
52
103
  end
104
+ when 500..599
105
+ raise Gslide::ServerError, msg || "ServerError"
53
106
  else
54
- msg = response_body["error"] ? response_body["error"]["message"] : "HTTPError"
55
- raise Gslide::HTTPError, msg
107
+ raise Gslide::HTTPError, msg || "HTTPError"
56
108
  end
57
109
  end
110
+
111
+ # @return [Hash, nil] the parsed body, {} for an empty body,
112
+ # or nil when the body is not a JSON object (e.g. an HTML error page).
113
+ def parse_body(body)
114
+ return {} if body.nil? || body.strip.empty?
115
+
116
+ parsed = JSON(body)
117
+ parsed.is_a?(Hash) ? parsed : nil
118
+ rescue JSON::ParserError
119
+ nil
120
+ end
121
+
122
+ def error_message(response_body)
123
+ error = response_body && response_body["error"]
124
+ error.is_a?(Hash) ? error["message"] : nil
125
+ end
126
+
127
+ # Drive reports rate limits as 403 with one of these reasons.
128
+ def rate_limited?(response_body)
129
+ error = response_body && response_body["error"]
130
+ return false unless error.is_a?(Hash) && error["errors"].is_a?(Array)
131
+
132
+ error["errors"].any? { |e| %w[rateLimitExceeded userRateLimitExceeded].include?(e["reason"]) }
133
+ end
58
134
  end
59
135
  end
60
136
  end
@@ -0,0 +1,68 @@
1
+ require "gslide/concerns/requests"
2
+
3
+ module Gslide
4
+ module Models
5
+ # Drive calls for decks: copy a template (into a folder), rename a file, and trash a file.
6
+ # Works with the narrow `drive.file` scope for files the user picked or the app created, and
7
+ # with shared drives.
8
+ #
9
+ # drive = Gslide::Models::Drive.new(auth: editor)
10
+ # deck_id = drive.copy(template_id, name: "Monthly Report (draft)", folder_id: folder_id)
11
+ # drive.rename(deck_id, name: "Monthly Report")
12
+ # drive.trash(deck_id)
13
+ class Drive
14
+ include Concerns::Requests
15
+
16
+ FILE_ID_PATTERN = /\A[a-zA-Z0-9\-_]+\z/
17
+
18
+ def initialize(auth: nil)
19
+ @auth = auth
20
+ end
21
+
22
+ # @param [String] file_id the file to copy, e.g. a template presentation.
23
+ # @param [String] name the copy's name.
24
+ # @param [String, nil] folder_id the folder to put the copy in; nil for the original's folder.
25
+ # @return [String] the copy's file id.
26
+ # @see https://developers.google.com/workspace/drive/api/reference/rest/v3/files/copy
27
+ def copy(file_id, name:, folder_id: nil)
28
+ body = { name: name }
29
+ body[:parents] = [checked(folder_id)] if folder_id
30
+ uri = URI(GOOGLE_DRIVE + "/#{checked(file_id)}/copy?supportsAllDrives=true")
31
+
32
+ post_request(uri, auth_token: @auth.token, body: body.to_json).fetch("id")
33
+ end
34
+
35
+ # @param [String] file_id the file to rename, e.g. a finished deck.
36
+ # @param [String] name its new name.
37
+ # @return [true]
38
+ # @see https://developers.google.com/workspace/drive/api/reference/rest/v3/files/update
39
+ def rename(file_id, name:)
40
+ uri = URI(GOOGLE_DRIVE + "/#{checked(file_id)}?supportsAllDrives=true")
41
+ patch_request(uri, auth_token: @auth.token, body: { name: name }.to_json)
42
+ true
43
+ end
44
+
45
+ # Moves the file to the trash (it can be restored from there for 30 days).
46
+ # @param [String] file_id the file to trash, e.g. a deck left by a failed run.
47
+ # @return [true] also when the file is already gone.
48
+ # @see https://developers.google.com/workspace/drive/api/reference/rest/v3/files/update
49
+ def trash(file_id)
50
+ uri = URI(GOOGLE_DRIVE + "/#{checked(file_id)}?supportsAllDrives=true")
51
+ patch_request(uri, auth_token: @auth.token, body: { trashed: true }.to_json)
52
+ true
53
+ rescue Gslide::NotFoundError
54
+ true
55
+ end
56
+
57
+ private
58
+
59
+ def checked(file_id)
60
+ unless file_id.to_s.match?(FILE_ID_PATTERN)
61
+ raise ArgumentError, "invalid Drive file id: #{file_id.inspect}"
62
+ end
63
+
64
+ file_id
65
+ end
66
+ end
67
+ end
68
+ end
@@ -1,11 +1,13 @@
1
1
  require "gslide/models/editor"
2
2
  require "gslide/models/presentation"
3
3
  require "gslide/models/spreadsheet"
4
+ require "gslide/models/drive"
4
5
 
5
6
  module Gslide
6
7
  module Models
7
8
  GOOGLE_SLIDES = "https://slides.googleapis.com/v1/presentations"
8
9
  GOOGLE_SHEETS = "https://sheets.googleapis.com/v4/spreadsheets"
10
+ GOOGLE_DRIVE = "https://www.googleapis.com/drive/v3/files"
9
11
  end
10
12
 
11
13
  include Models
@@ -1,3 +1,4 @@
1
+ require "securerandom"
1
2
  require "gslide/concerns/requests"
2
3
 
3
4
  module Gslide
@@ -7,10 +8,33 @@ module Gslide
7
8
 
8
9
  attr_reader :id
9
10
 
10
- PRESENTATION_PATTERN = %r[/presentation/d/([a-zA-Z0-9\-_]+)?]
11
+ PRESENTATION_ID_PATTERN = /[a-zA-Z0-9\-_]+/
12
+ PRESENTATION_PATTERN = %r[/presentation/d/(#{PRESENTATION_ID_PATTERN})?]
13
+
14
+ # An object ID (for a page, shape or table) starts with a word character,
15
+ # followed by word characters, dashes or colons; 5 to 50 characters long.
16
+ # @see https://developers.google.com/slides/api/reference/rest/v1/presentations.pages#Page.FIELDS.object_id
17
+ OBJECT_ID_PATTERN = /[a-zA-Z0-9_][a-zA-Z0-9_\-:]*/
18
+ OBJECT_ID_LENGTHS = 5..50
19
+
20
+ # @param [Integer] length from 5 to 50.
21
+ # @return [String] a random object ID for a new page element, e.g. "aZ3kq09XbP1t".
22
+ def self.generate_object_id(length = 12)
23
+ unless OBJECT_ID_LENGTHS.cover?(length)
24
+ raise ArgumentError, "object ID length must be within #{OBJECT_ID_LENGTHS}, not #{length}"
25
+ end
26
+
27
+ SecureRandom.alphanumeric(length)
28
+ end
29
+
30
+ # @param [String] id_or_url a presentation id, or a sharing url holding one.
31
+ # @return [String] the presentation id, or the argument when it is not a url.
32
+ def self.file_id_in(id_or_url)
33
+ (url_id = id_or_url.match(PRESENTATION_PATTERN)) ? url_id[1] : id_or_url
34
+ end
11
35
 
12
36
  def initialize(id_or_url, auth: nil)
13
- @id = (url_id = id_or_url.match(PRESENTATION_PATTERN)) ? url_id[1] : id_or_url
37
+ @id = self.class.file_id_in(id_or_url)
14
38
  @auth = auth
15
39
  end
16
40
 
@@ -40,7 +64,7 @@ module Gslide
40
64
  response_body["presentationId"] == @id
41
65
  rescue Gslide::QuotaExceededError
42
66
  if (retries += 1) < 3
43
- sleep 10 + retries * retries * 10
67
+ sleep retry_delay(retries)
44
68
 
45
69
  retry
46
70
  end
@@ -40,6 +40,23 @@ module Gslide
40
40
  parsed_body = get
41
41
  parsed_body[:sheets].collect { |h| Sheet.new(h) }
42
42
  end
43
+
44
+ # @param [Hash] options the request body.
45
+ # @see https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets.values/append
46
+ def values_append(notation, options = {})
47
+ uri = url_escape_uri(GOOGLE_SHEETS + "/#{@id}/values/'#{notation}':append?valueInputOption=USER_ENTERED")
48
+ request_body = options.convert_keys { |k| k.to_s.lower_camel_case }.to_json
49
+
50
+ response_body = post_request(uri, auth_token: @auth.token, body: request_body)
51
+ response_body
52
+ end
53
+
54
+ private
55
+
56
+ def url_escape_uri(uri_string)
57
+ parser = URI::Parser.new
58
+ URI parser.escape(uri_string)
59
+ end
43
60
  end
44
61
 
45
62
  class SpreadsheetDraft
@@ -61,10 +78,11 @@ module Gslide
61
78
  end
62
79
 
63
80
  class Sheet
64
- attr_reader :id, :charts
81
+ attr_reader :id, :title, :charts
65
82
 
66
83
  def initialize(options = {})
67
84
  @id = options[:properties][:sheet_id]
85
+ @title = options[:properties][:title]
68
86
  @charts = options[:charts].collect { |h| Chart.new(h) } if options[:charts]
69
87
  end
70
88
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gslide
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/gslide.rb CHANGED
@@ -9,5 +9,11 @@ module Gslide
9
9
  class Error < StandardError; end
10
10
  class HTTPError < Error; end
11
11
  class UnauthorizedError < HTTPError; end
12
+ class ForbiddenError < HTTPError; end
13
+ class NotFoundError < HTTPError; end
12
14
  class QuotaExceededError < HTTPError; end
15
+ class ServerError < HTTPError; end
16
+
17
+ # Raised when Google does not respond in time.
18
+ class TimeoutError < Error; end
13
19
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gslide
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kang-Kyu Lee
@@ -23,6 +23,7 @@ files:
23
23
  - Rakefile
24
24
  - lib/gslide.rb
25
25
  - lib/gslide/concerns/requests.rb
26
+ - lib/gslide/models/drive.rb
26
27
  - lib/gslide/models/editor.rb
27
28
  - lib/gslide/models/models.rb
28
29
  - lib/gslide/models/presentation.rb
@@ -52,7 +53,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
52
53
  - !ruby/object:Gem::Version
53
54
  version: '0'
54
55
  requirements: []
55
- rubygems_version: 3.6.7
56
+ rubygems_version: 4.0.6
56
57
  specification_version: 4
57
58
  summary: Google Slides API client.
58
59
  test_files: []