rdeck 0.1.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.
@@ -0,0 +1,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdeck
4
+ class RequestBuilder
5
+ def initialize(presentation)
6
+ @presentation = presentation
7
+ @object_id_counter = 0
8
+ end
9
+
10
+ def build_slide_update_requests(_slide_index, slide, existing_slide)
11
+ requests = []
12
+
13
+ slide_id = existing_slide&.object_id_prop || generate_object_id
14
+
15
+ requests += build_clear_content_requests(existing_slide) if existing_slide
16
+
17
+ slide.titles.each_with_index do |title, index|
18
+ requests += build_text_update_requests_for_title(slide_id, [title], index)
19
+ end
20
+
21
+ requests += build_text_update_requests(slide_id, slide.subtitles, 'SUBTITLE')
22
+
23
+ requests += build_body_update_requests(slide_id, slide.bodies)
24
+
25
+ requests += build_image_update_requests(slide_id, slide.images)
26
+
27
+ requests += build_table_update_requests(slide_id, slide.tables)
28
+
29
+ if slide.speaker_note && !slide.speaker_note.empty?
30
+ requests << build_speaker_note_request(slide_id, slide.speaker_note)
31
+ end
32
+
33
+ requests
34
+ end
35
+
36
+ def build_layout_update_request(slide_id, layout_name)
37
+ layout = @presentation.find_layout(layout_name)
38
+ return nil unless layout
39
+
40
+ {
41
+ update_slide_properties: {
42
+ object_id_prop: slide_id,
43
+ slide_properties: {
44
+ layout_object_id: layout.object_id_prop
45
+ },
46
+ fields: 'layoutObjectId'
47
+ }
48
+ }
49
+ end
50
+
51
+ def build_clear_content_requests(slide)
52
+ requests = []
53
+
54
+ slide.page_elements&.each do |element|
55
+ next if element.shape&.placeholder
56
+
57
+ requests << {
58
+ delete_object: {
59
+ object_id_prop: element.object_id_prop
60
+ }
61
+ }
62
+ end
63
+
64
+ requests
65
+ end
66
+
67
+ def build_text_update_requests_for_title(slide_id, texts, index)
68
+ title_types = %w[TITLE CENTERED_TITLE]
69
+ requests = []
70
+
71
+ texts.each do |text|
72
+ placeholder = nil
73
+ title_types.each do |type|
74
+ placeholder = find_placeholder(slide_id, type, index)
75
+ break if placeholder
76
+ end
77
+
78
+ next unless placeholder
79
+
80
+ if placeholder_has_text?(placeholder)
81
+ requests << {
82
+ delete_text: {
83
+ object_id_prop: placeholder.object_id_prop,
84
+ text_range: {
85
+ type: 'ALL'
86
+ }
87
+ }
88
+ }
89
+ end
90
+
91
+ requests << {
92
+ insert_text: {
93
+ object_id_prop: placeholder.object_id_prop,
94
+ text: text,
95
+ insertion_index: 0
96
+ }
97
+ }
98
+ end
99
+
100
+ requests
101
+ end
102
+
103
+ def build_text_update_requests(slide_id, texts, placeholder_type)
104
+ requests = []
105
+
106
+ texts.each_with_index do |text, index|
107
+ placeholder = find_placeholder(slide_id, placeholder_type, index)
108
+ next unless placeholder
109
+
110
+ if placeholder_has_text?(placeholder)
111
+ requests << {
112
+ delete_text: {
113
+ object_id_prop: placeholder.object_id_prop,
114
+ text_range: {
115
+ type: 'ALL'
116
+ }
117
+ }
118
+ }
119
+ end
120
+
121
+ requests << {
122
+ insert_text: {
123
+ object_id_prop: placeholder.object_id_prop,
124
+ text: text,
125
+ insertion_index: 0
126
+ }
127
+ }
128
+ end
129
+
130
+ requests
131
+ end
132
+
133
+ def build_body_update_requests(slide_id, bodies)
134
+ requests = []
135
+
136
+ bodies.each_with_index do |body, index|
137
+ placeholder = find_placeholder(slide_id, 'BODY', index)
138
+ next unless placeholder
139
+
140
+ placeholder_id = placeholder.object_id_prop
141
+
142
+ if placeholder_has_text?(placeholder)
143
+ requests << {
144
+ delete_text: {
145
+ object_id_prop: placeholder_id,
146
+ text_range: {
147
+ type: 'ALL'
148
+ }
149
+ }
150
+ }
151
+ end
152
+
153
+ insertion_index = 0
154
+ body.paragraphs.each do |paragraph|
155
+ text = paragraph_to_text(paragraph)
156
+ requests << {
157
+ insert_text: {
158
+ object_id_prop: placeholder_id,
159
+ text: "#{text}\n",
160
+ insertion_index: insertion_index
161
+ }
162
+ }
163
+
164
+ if paragraph.bullet != Paragraph::BULLET_NONE
165
+ requests << build_bullet_request(
166
+ placeholder_id,
167
+ insertion_index,
168
+ insertion_index + text.length,
169
+ paragraph.bullet,
170
+ paragraph.nesting
171
+ )
172
+ end
173
+
174
+ requests += build_text_style_requests(
175
+ placeholder_id,
176
+ insertion_index,
177
+ paragraph.fragments
178
+ )
179
+
180
+ insertion_index += text.length + 1
181
+ end
182
+ end
183
+
184
+ requests
185
+ end
186
+
187
+ def build_image_update_requests(slide_id, images)
188
+ requests = []
189
+
190
+ images.each_with_index do |image, _index|
191
+ url = upload_image_to_drive(image)
192
+ next unless url
193
+
194
+ image_id = generate_object_id
195
+ requests << {
196
+ create_image: {
197
+ object_id_prop: image_id,
198
+ url: url,
199
+ element_properties: {
200
+ page_object_id: slide_id,
201
+ size: {
202
+ width: { magnitude: 400, unit: 'PT' },
203
+ height: { magnitude: 300, unit: 'PT' }
204
+ },
205
+ transform: {
206
+ scale_x: 1,
207
+ scale_y: 1,
208
+ translate_x: 100,
209
+ translate_y: 100,
210
+ unit: 'PT'
211
+ }
212
+ }
213
+ }
214
+ }
215
+ end
216
+
217
+ requests
218
+ end
219
+
220
+ def build_table_update_requests(slide_id, tables)
221
+ requests = []
222
+
223
+ tables.each do |table|
224
+ next if table.rows.empty?
225
+
226
+ rows = table.rows.size
227
+ cols = table.rows.first.cells.size
228
+
229
+ table_id = generate_object_id
230
+ requests << {
231
+ create_table: {
232
+ object_id_prop: table_id,
233
+ element_properties: {
234
+ page_object_id: slide_id,
235
+ size: {
236
+ width: { magnitude: 500, unit: 'PT' },
237
+ height: { magnitude: rows * 30, unit: 'PT' }
238
+ },
239
+ transform: {
240
+ scale_x: 1,
241
+ scale_y: 1,
242
+ translate_x: 50,
243
+ translate_y: 150,
244
+ unit: 'PT'
245
+ }
246
+ },
247
+ rows: rows,
248
+ columns: cols
249
+ }
250
+ }
251
+
252
+ table.rows.each_with_index do |row, row_idx|
253
+ row.cells.each_with_index do |cell, col_idx|
254
+ text = cell.text
255
+
256
+ requests << {
257
+ insert_text: {
258
+ object_id_prop: table_id,
259
+ cell_location: {
260
+ row_index: row_idx,
261
+ column_index: col_idx
262
+ },
263
+ text: text,
264
+ insertion_index: 0
265
+ }
266
+ }
267
+
268
+ next unless cell.is_header
269
+
270
+ requests << build_table_cell_style_request(
271
+ table_id,
272
+ row_idx,
273
+ col_idx,
274
+ bold: true
275
+ )
276
+ end
277
+ end
278
+ end
279
+
280
+ requests
281
+ end
282
+
283
+ def build_speaker_note_request(slide_id, _note)
284
+ {
285
+ create_paragraph_bullets: {
286
+ object_id_prop: "#{slide_id}_notes",
287
+ text_range: {
288
+ type: 'ALL'
289
+ }
290
+ }
291
+ }
292
+ end
293
+
294
+ def build_bullet_request(object_id, start_idx, end_idx, bullet_type, _nesting)
295
+ glyph = case bullet_type
296
+ when Paragraph::BULLET_NUMBERED
297
+ 'NUMBERED_DECIMAL_NESTED'
298
+ else
299
+ 'BULLET_DISC_CIRCLE_SQUARE'
300
+ end
301
+
302
+ {
303
+ create_paragraph_bullets: {
304
+ object_id_prop: object_id,
305
+ text_range: {
306
+ start_index: start_idx,
307
+ end_index: end_idx,
308
+ type: 'FIXED_RANGE'
309
+ },
310
+ bullet_preset: glyph
311
+ }
312
+ }
313
+ end
314
+
315
+ def build_text_style_requests(object_id, base_index, fragments)
316
+ requests = []
317
+ index = base_index
318
+
319
+ fragments.each do |fragment|
320
+ length = fragment.value.length
321
+ next if length.zero?
322
+
323
+ style = {}
324
+ style[:bold] = true if fragment.bold
325
+ style[:italic] = true if fragment.italic
326
+ style[:link] = { url: fragment.link } if fragment.link && !fragment.link.empty?
327
+
328
+ if fragment.code
329
+ style[:font_family] = 'Courier New'
330
+ style[:foreground_color] = {
331
+ opaque_color: {
332
+ rgb_color: { red: 0.2, green: 0.2, blue: 0.2 }
333
+ }
334
+ }
335
+ end
336
+
337
+ unless style.empty?
338
+ requests << {
339
+ update_text_style: {
340
+ object_id_prop: object_id,
341
+ text_range: {
342
+ start_index: index,
343
+ end_index: index + length,
344
+ type: 'FIXED_RANGE'
345
+ },
346
+ style: style,
347
+ fields: style.keys.join(',')
348
+ }
349
+ }
350
+ end
351
+
352
+ index += length
353
+ end
354
+
355
+ requests
356
+ end
357
+
358
+ def build_table_cell_style_request(table_id, row, col, bold: false)
359
+ {
360
+ update_text_style: {
361
+ object_id_prop: table_id,
362
+ cell_location: {
363
+ row_index: row,
364
+ column_index: col
365
+ },
366
+ style: {
367
+ bold: bold
368
+ },
369
+ fields: 'bold'
370
+ }
371
+ }
372
+ end
373
+
374
+ private
375
+
376
+ def generate_object_id
377
+ @object_id_counter += 1
378
+ "obj_#{Time.now.to_i}_#{@object_id_counter}"
379
+ end
380
+
381
+ def find_placeholder(slide_id, type, index)
382
+ presentation_data = @presentation.instance_variable_get(:@presentation)
383
+ return nil unless presentation_data
384
+
385
+ slide = presentation_data.slides&.find { |s| s.object_id_prop == slide_id }
386
+ return nil unless slide
387
+
388
+ placeholders = []
389
+ slide.page_elements&.each do |element|
390
+ next unless element.shape&.placeholder
391
+
392
+ placeholder_type = element.shape.placeholder.type
393
+ placeholders << element if placeholder_type == type
394
+ end
395
+
396
+ placeholders[index]
397
+ end
398
+
399
+ def placeholder_has_text?(placeholder)
400
+ return false unless placeholder
401
+ return false unless placeholder.shape&.text
402
+
403
+ placeholder.shape.text.text_elements&.any? do |element|
404
+ element.text_run && !element.text_run.content.to_s.strip.empty?
405
+ end
406
+ end
407
+
408
+ def paragraph_to_text(paragraph)
409
+ paragraph.fragments.map(&:value).join
410
+ end
411
+
412
+ def upload_image_to_drive(_image)
413
+ nil
414
+ end
415
+ end
416
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdeck
4
+ class RetryHandler
5
+ MAX_RETRIES = 10
6
+ INITIAL_BACKOFF = 1
7
+ MAX_BACKOFF = 30
8
+
9
+ class << self
10
+ attr_accessor :verbose
11
+
12
+ def with_retry(max_retries: MAX_RETRIES, verbose: nil)
13
+ retries = 0
14
+ backoff = INITIAL_BACKOFF
15
+ should_log = verbose.nil? ? self.verbose : verbose
16
+
17
+ begin
18
+ yield
19
+ rescue Google::Apis::RateLimitError, Google::Apis::ServerError => e
20
+ retries += 1
21
+
22
+ raise Error, "Max retries (#{max_retries}) exceeded: #{e.message}" unless retries <= max_retries
23
+
24
+ if should_log
25
+ warn "API error (attempt #{retries}/#{max_retries}): #{e.message}"
26
+ warn "Retrying in #{backoff} seconds..."
27
+ end
28
+ sleep backoff
29
+
30
+ backoff = [(backoff * 2) + rand, MAX_BACKOFF].min
31
+ retry
32
+ rescue Google::Apis::ClientError => e
33
+ error_details = e.body ? "\nDetails: #{e.body}" : ''
34
+ raise Error, "API client error: #{e.message}#{error_details}"
35
+ rescue Google::Apis::AuthorizationError => e
36
+ raise Error, "Authorization error: #{e.message}. Please check your credentials."
37
+ rescue StandardError => e
38
+ raise Error, "Unexpected error: #{e.class}: #{e.message}"
39
+ end
40
+ end
41
+
42
+ def with_batch_retry(requests, batch_size: 1000, verbose: nil)
43
+ results = []
44
+
45
+ requests.each_slice(batch_size) do |batch|
46
+ result = with_retry(verbose: verbose) do
47
+ yield(batch)
48
+ end
49
+ results << result
50
+ end
51
+
52
+ results
53
+ end
54
+ end
55
+
56
+ self.verbose = true
57
+ end
58
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdeck
4
+ class Slide
5
+ attr_accessor :layout, :freeze, :skip, :titles, :title_bodies, :subtitles, :subtitle_bodies, :bodies, :images,
6
+ :block_quotes, :tables, :speaker_note, :new_slide, :delete_slide
7
+
8
+ def initialize(attrs = {})
9
+ @layout = attrs[:layout] || ''
10
+ @freeze = attrs[:freeze] || false
11
+ @skip = attrs[:skip] || false
12
+ @titles = attrs[:titles] || []
13
+ @title_bodies = attrs[:title_bodies] || []
14
+ @subtitles = attrs[:subtitles] || []
15
+ @subtitle_bodies = attrs[:subtitle_bodies] || []
16
+ @bodies = attrs[:bodies] || []
17
+ @images = attrs[:images] || []
18
+ @block_quotes = attrs[:block_quotes] || []
19
+ @tables = attrs[:tables] || []
20
+ @speaker_note = attrs[:speaker_note] || ''
21
+ @new_slide = false
22
+ @delete_slide = false
23
+ end
24
+
25
+ def ==(other)
26
+ return false unless other.is_a?(Slide)
27
+
28
+ layout == other.layout &&
29
+ titles == other.titles &&
30
+ title_bodies == other.title_bodies &&
31
+ subtitles == other.subtitles &&
32
+ subtitle_bodies == other.subtitle_bodies &&
33
+ bodies_equal?(bodies, other.bodies) &&
34
+ images_equivalent?(images, other.images) &&
35
+ block_quotes == other.block_quotes &&
36
+ tables == other.tables &&
37
+ speaker_note == other.speaker_note
38
+ end
39
+
40
+ def empty?
41
+ titles.empty? &&
42
+ subtitles.empty? &&
43
+ bodies.empty? &&
44
+ images.empty? &&
45
+ block_quotes.empty? &&
46
+ tables.empty?
47
+ end
48
+
49
+ private
50
+
51
+ def bodies_equal?(a, b)
52
+ return true if a == b
53
+ return false if a.size != b.size
54
+
55
+ a.zip(b).all? { |body_a, body_b| body_a == body_b }
56
+ end
57
+
58
+ def images_equivalent?(a, b)
59
+ return true if a.size != b.size
60
+
61
+ a.zip(b).all? { |img_a, img_b| img_a&.equivalent?(img_b) }
62
+ end
63
+ end
64
+ end