gslide 0.1.3 → 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 +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +12 -0
- data/lib/gslide/concerns/requests.rb +103 -29
- data/lib/gslide/models/drive.rb +68 -0
- data/lib/gslide/models/models.rb +2 -0
- data/lib/gslide/models/presentation.rb +18 -1
- data/lib/gslide/version.rb +1 -1
- data/lib/gslide.rb +6 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3f719011cb4ab8f9e07b8f03bf76609302e6ea2491d1f6dd8dad5c7534c0eed
|
|
4
|
+
data.tar.gz: eca34767fba2927ee5d01966c261a2251146d89922f942643f91b3ae56581cec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cd49143158ee8e357dca44cfa114b5be71eee28bec81499c0d8e81ee575550b8cdf214c8a2eba2e41a41e602d7d485ce3c7032779301a897360d53be4a7cde4a
|
|
7
|
+
data.tar.gz: 0c0657e347e66e54d1408490bf587ac814fab41968ef071894e8aad84ae506e507ad3ce887b3f4be56e79a6f8373c68829e0c7990ffdb68dda85f9125d88d377
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
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
|
+
|
|
1
14
|
## [0.1.3] - 2026-08-19
|
|
2
15
|
|
|
3
16
|
- Add `Gslide::Presentation::PRESENTATION_ID_PATTERN` and `Gslide::Presentation.file_id_in`, so callers can validate a presentation id without repeating the pattern
|
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,3 +1,4 @@
|
|
|
1
|
+
require "json"
|
|
1
2
|
require "net/http"
|
|
2
3
|
|
|
3
4
|
module Gslide
|
|
@@ -5,58 +6,131 @@ module Gslide
|
|
|
5
6
|
# @private
|
|
6
7
|
module Concerns
|
|
7
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
|
+
|
|
8
16
|
def get_request(uri, auth_token: "")
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
11
24
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
end
|
|
15
|
-
response_body = JSON(res.body)
|
|
16
|
-
|
|
17
|
-
case res
|
|
18
|
-
when Net::HTTPSuccess
|
|
19
|
-
response_body
|
|
20
|
-
when Net::HTTPUnauthorized
|
|
21
|
-
msg = response_body["error"] ? response_body["error"]["message"] : "Unauthorized"
|
|
22
|
-
raise Gslide::UnauthorizedError, msg
|
|
23
|
-
else
|
|
24
|
-
msg = response_body["error"] ? response_body["error"]["message"] : "HTTPError"
|
|
25
|
-
raise Gslide::HTTPError, msg
|
|
25
|
+
sleep retry_delay(attempts)
|
|
26
|
+
retry
|
|
26
27
|
end
|
|
27
28
|
end
|
|
28
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.
|
|
29
33
|
def post_request(uri, auth_token: "", body: {})
|
|
30
34
|
req = Net::HTTP::Post.new(uri.to_s)
|
|
31
35
|
req.initialize_http_header 'Content-Type' => 'application/json'
|
|
32
|
-
req
|
|
36
|
+
req.body = body
|
|
33
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'
|
|
34
45
|
req.body = body
|
|
35
46
|
|
|
36
|
-
|
|
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|
|
|
37
64
|
http.request(req)
|
|
38
65
|
end
|
|
39
66
|
|
|
40
|
-
|
|
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
|
|
41
93
|
|
|
42
|
-
|
|
43
|
-
when
|
|
44
|
-
|
|
45
|
-
when
|
|
46
|
-
msg = response_body["error"] ? response_body["error"]["message"] : "Unauthorized"
|
|
47
|
-
raise Gslide::UnauthorizedError, msg
|
|
48
|
-
when Net::HTTPTooManyRequests
|
|
94
|
+
raise Gslide::ForbiddenError, msg || "Forbidden"
|
|
95
|
+
when 404
|
|
96
|
+
raise Gslide::NotFoundError, msg || "NotFound"
|
|
97
|
+
when 429
|
|
49
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'."
|
|
50
|
-
if
|
|
99
|
+
if msg =~ /Quota exceeded/
|
|
51
100
|
raise Gslide::QuotaExceededError, msg
|
|
52
101
|
else
|
|
53
102
|
raise Gslide::HTTPError, msg || "TooManyRequests"
|
|
54
103
|
end
|
|
104
|
+
when 500..599
|
|
105
|
+
raise Gslide::ServerError, msg || "ServerError"
|
|
55
106
|
else
|
|
56
|
-
|
|
57
|
-
raise Gslide::HTTPError, msg
|
|
107
|
+
raise Gslide::HTTPError, msg || "HTTPError"
|
|
58
108
|
end
|
|
59
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
|
|
60
134
|
end
|
|
61
135
|
end
|
|
62
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
|
data/lib/gslide/models/models.rb
CHANGED
|
@@ -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
|
|
@@ -10,6 +11,22 @@ module Gslide
|
|
|
10
11
|
PRESENTATION_ID_PATTERN = /[a-zA-Z0-9\-_]+/
|
|
11
12
|
PRESENTATION_PATTERN = %r[/presentation/d/(#{PRESENTATION_ID_PATTERN})?]
|
|
12
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
|
+
|
|
13
30
|
# @param [String] id_or_url a presentation id, or a sharing url holding one.
|
|
14
31
|
# @return [String] the presentation id, or the argument when it is not a url.
|
|
15
32
|
def self.file_id_in(id_or_url)
|
|
@@ -47,7 +64,7 @@ module Gslide
|
|
|
47
64
|
response_body["presentationId"] == @id
|
|
48
65
|
rescue Gslide::QuotaExceededError
|
|
49
66
|
if (retries += 1) < 3
|
|
50
|
-
sleep
|
|
67
|
+
sleep retry_delay(retries)
|
|
51
68
|
|
|
52
69
|
retry
|
|
53
70
|
end
|
data/lib/gslide/version.rb
CHANGED
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.
|
|
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
|