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,449 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'commonmarker'
4
+ require 'json'
5
+
6
+ module Rdeck
7
+ module Markdown
8
+ class Parser
9
+ FRONTMATTER_REGEX = /\A---\n(.*?)\n---\n/m
10
+ PAGE_SEPARATOR_REGEX = /^-{3,}$/
11
+
12
+ attr_reader :base_dir
13
+
14
+ def initialize(base_dir = '.')
15
+ @base_dir = File.expand_path(base_dir)
16
+ end
17
+
18
+ def parse(content, config = nil)
19
+ content = content.gsub("\r\n", "\n").tr("\r", "\n")
20
+
21
+ frontmatter, body = Frontmatter.extract(content)
22
+ frontmatter = apply_config(frontmatter, config)
23
+
24
+ pages = split_pages(body)
25
+
26
+ contents = pages.map do |page|
27
+ parse_page(page, frontmatter)
28
+ end
29
+
30
+ MarkdownDocument.new(frontmatter: frontmatter, contents: contents)
31
+ end
32
+
33
+ def parse_file(path, config = nil)
34
+ content = File.read(path)
35
+ @base_dir = File.dirname(File.expand_path(path))
36
+ parse(content, config)
37
+ end
38
+
39
+ private
40
+
41
+ def apply_config(frontmatter, config)
42
+ return frontmatter unless config
43
+
44
+ frontmatter ||= Frontmatter.new
45
+ frontmatter.breaks = config.breaks if frontmatter.breaks.nil? && !config.breaks.nil?
46
+ frontmatter.code_block_to_image_command ||= config.code_block_to_image_command
47
+ frontmatter.defaults = (frontmatter.defaults || []) + (config.defaults || [])
48
+ frontmatter
49
+ end
50
+
51
+ def split_pages(content)
52
+ pages = []
53
+ current_page = []
54
+ in_code_block = false
55
+ code_fence_char = nil
56
+
57
+ content.lines.each do |line|
58
+ if line =~ /^(`{3,}|~{3,})/
59
+ fence = ::Regexp.last_match(1)[0]
60
+ if !in_code_block
61
+ in_code_block = true
62
+ code_fence_char = fence
63
+ elsif fence == code_fence_char
64
+ in_code_block = false
65
+ code_fence_char = nil
66
+ end
67
+ end
68
+
69
+ if !in_code_block && line.match(PAGE_SEPARATOR_REGEX) && !line.match(/^={3,}$/)
70
+ pages << current_page.join
71
+ current_page = []
72
+ else
73
+ current_page << line
74
+ end
75
+ end
76
+
77
+ pages << current_page.join unless current_page.empty?
78
+ pages
79
+ end
80
+
81
+ def parse_page(content, _frontmatter)
82
+ page_config, clean_content = extract_page_config(content)
83
+
84
+ speaker_note, body_content = extract_speaker_notes(clean_content)
85
+
86
+ doc = Commonmarker.parse(body_content, options: {
87
+ extension: { table: true, strikethrough: true },
88
+ parse: { smart: false }
89
+ })
90
+
91
+ content_obj = process_ast(doc)
92
+ content_obj.layout = page_config[:layout] if page_config[:layout]
93
+ content_obj.freeze = page_config[:freeze] if page_config[:freeze]
94
+ content_obj.skip = page_config[:skip] if page_config[:skip]
95
+ content_obj.ignore = page_config[:ignore] if page_config[:ignore]
96
+ content_obj.speaker_note = speaker_note
97
+
98
+ content_obj
99
+ end
100
+
101
+ def extract_page_config(content)
102
+ config = {}
103
+ clean_lines = []
104
+
105
+ content.lines.each do |line|
106
+ if line =~ /<!--\s*(\{.*\})\s*-->/
107
+ json_str = ::Regexp.last_match(1)
108
+ begin
109
+ parsed = JSON.parse(json_str, symbolize_names: true)
110
+ config.merge!(parsed)
111
+ rescue JSON::ParserError
112
+ clean_lines << line
113
+ end
114
+ else
115
+ clean_lines << line
116
+ end
117
+ end
118
+
119
+ [config, clean_lines.join]
120
+ end
121
+
122
+ def extract_speaker_notes(content)
123
+ notes = []
124
+ clean_lines = []
125
+
126
+ content.lines.each do |line|
127
+ if line =~ /<!--\s*(.*?)\s*-->/
128
+ note = ::Regexp.last_match(1).strip
129
+ notes << note unless note.empty?
130
+ else
131
+ clean_lines << line
132
+ end
133
+ end
134
+
135
+ [notes.join("\n"), clean_lines.join]
136
+ end
137
+
138
+ def process_ast(doc)
139
+ content = Content.new
140
+
141
+ heading_levels = find_heading_levels(doc)
142
+ title_level = heading_levels.min
143
+ subtitle_level = title_level ? title_level + 1 : nil
144
+
145
+ doc.walk do |node|
146
+ case node.type
147
+ when :heading
148
+ level = node.header_level
149
+ text_content = extract_text(node)
150
+
151
+ if level == title_level
152
+ content.titles << text_content
153
+ content.title_bodies << node_to_body(node)
154
+ elsif level == subtitle_level
155
+ content.subtitles << text_content
156
+ content.subtitle_bodies << node_to_body(node)
157
+ else
158
+ content.bodies << node_to_body(node)
159
+ end
160
+
161
+ when :paragraph
162
+ next if node.parent && %i[item list block_quote].include?(node.parent.type)
163
+
164
+ content.bodies << node_to_body(node)
165
+
166
+ when :list
167
+ content.bodies << list_to_body(node)
168
+
169
+ when :block_quote
170
+ content.block_quotes << node_to_block_quote(node)
171
+
172
+ when :table
173
+ content.tables << node_to_table(node)
174
+
175
+ when :code_block
176
+ content.code_blocks << extract_code_block(node)
177
+
178
+ when :image
179
+ content.images << node_to_image(node)
180
+ end
181
+ end
182
+
183
+ content
184
+ end
185
+
186
+ def find_heading_levels(doc)
187
+ levels = []
188
+ doc.walk do |node|
189
+ levels << node.header_level if node.type == :heading
190
+ end
191
+ levels.uniq.sort
192
+ end
193
+
194
+ def extract_text(node)
195
+ text = []
196
+ node.walk do |child|
197
+ text << child.string_content if child.type == :text
198
+ end
199
+ text.join
200
+ end
201
+
202
+ def node_to_body(node)
203
+ paragraphs = node_to_paragraphs(node)
204
+ Body.new(paragraphs: paragraphs)
205
+ end
206
+
207
+ def node_to_paragraphs(node, bullet: Paragraph::BULLET_NONE, nesting: 0)
208
+ case node.type
209
+ when :heading, :paragraph
210
+ fragments = extract_fragments(node)
211
+ [Paragraph.new(fragments: fragments, bullet: bullet, nesting: nesting)]
212
+
213
+ when :text
214
+ [Paragraph.new(fragments: [Fragment.new(value: node.string_content)], bullet: bullet, nesting: nesting)]
215
+
216
+ else
217
+ []
218
+ end
219
+ end
220
+
221
+ def extract_fragments(node)
222
+ fragments = []
223
+ current_styles = { bold: false, italic: false, code: false, link: '' }
224
+
225
+ node.each do |child|
226
+ fragments.concat(process_inline_node(child, current_styles.dup))
227
+ end
228
+
229
+ fragments
230
+ end
231
+
232
+ def process_inline_node(node, styles)
233
+ case node.type
234
+ when :text
235
+ [Fragment.new(
236
+ value: node.string_content,
237
+ bold: styles[:bold],
238
+ italic: styles[:italic],
239
+ code: styles[:code],
240
+ link: styles[:link]
241
+ )]
242
+
243
+ when :strong
244
+ styles[:bold] = true
245
+ fragments = []
246
+ node.each { |child| fragments.concat(process_inline_node(child, styles)) }
247
+ fragments
248
+
249
+ when :emph
250
+ styles[:italic] = true
251
+ fragments = []
252
+ node.each { |child| fragments.concat(process_inline_node(child, styles)) }
253
+ fragments
254
+
255
+ when :code
256
+ [Fragment.new(
257
+ value: node.string_content,
258
+ bold: styles[:bold],
259
+ italic: styles[:italic],
260
+ code: true,
261
+ link: styles[:link]
262
+ )]
263
+
264
+ when :link
265
+ styles[:link] = node.url
266
+ fragments = []
267
+ node.each { |child| fragments.concat(process_inline_node(child, styles)) }
268
+ fragments
269
+
270
+ else
271
+ []
272
+ end
273
+ end
274
+
275
+ def list_to_body(node)
276
+ paragraphs = []
277
+ process_list_items(node, paragraphs, 0)
278
+ Body.new(paragraphs: paragraphs)
279
+ end
280
+
281
+ def process_list_items(list_node, paragraphs, nesting)
282
+ bullet = list_node.list_type == :ordered_list ? Paragraph::BULLET_NUMBERED : Paragraph::BULLET_DASH
283
+
284
+ list_node.each do |item|
285
+ next unless item.type == :item
286
+
287
+ item.each do |child|
288
+ case child.type
289
+ when :paragraph
290
+ fragments = extract_fragments(child)
291
+ paragraphs << Paragraph.new(fragments: fragments, bullet: bullet, nesting: nesting)
292
+
293
+ when :list
294
+ process_list_items(child, paragraphs, nesting + 1)
295
+ end
296
+ end
297
+ end
298
+ end
299
+
300
+ def node_to_block_quote(node)
301
+ paragraphs = []
302
+ node.each do |child|
303
+ paragraphs.concat(node_to_paragraphs(child))
304
+ end
305
+ BlockQuote.new(paragraphs: paragraphs, nesting: 0)
306
+ end
307
+
308
+ def node_to_table(node)
309
+ rows = []
310
+ is_header = true
311
+
312
+ node.each do |row_node|
313
+ next unless row_node.type == :table_row
314
+
315
+ cells = []
316
+ row_node.each do |cell_node|
317
+ next unless cell_node.type == :table_cell
318
+
319
+ fragments = extract_fragments(cell_node)
320
+ alignment = cell_alignment(cell_node)
321
+ cells << TableCell.new(fragments: fragments, alignment: alignment, is_header: is_header)
322
+ end
323
+
324
+ rows << TableRow.new(cells: cells)
325
+ is_header = false
326
+ end
327
+
328
+ Table.new(rows: rows)
329
+ end
330
+
331
+ def cell_alignment(_cell_node)
332
+ TableCell::ALIGN_START
333
+ end
334
+
335
+ def extract_code_block(node)
336
+ {
337
+ lang: node.fence_info || '',
338
+ content: node.string_content
339
+ }
340
+ end
341
+
342
+ def node_to_image(node)
343
+ url = node.url
344
+ path = resolve_image_path(url)
345
+ Image.new(path, from_markdown: true, link: node.title || '')
346
+ rescue StandardError => e
347
+ warn "Failed to load image #{url}: #{e.message}"
348
+ nil
349
+ end
350
+
351
+ def resolve_image_path(url)
352
+ return url if url.start_with?('http://', 'https://')
353
+
354
+ File.join(@base_dir, url)
355
+ end
356
+ end
357
+
358
+ class MarkdownDocument
359
+ attr_accessor :frontmatter, :contents
360
+
361
+ def initialize(frontmatter: nil, contents: [])
362
+ @frontmatter = frontmatter
363
+ @contents = contents
364
+ end
365
+
366
+ def to_slides(code_block_to_image_command = nil)
367
+ contents.map.with_index do |content, index|
368
+ content_to_slide(content, index + 1, code_block_to_image_command)
369
+ end
370
+ end
371
+
372
+ private
373
+
374
+ def content_to_slide(content, page_number, code_block_to_image_command)
375
+ slide = Slide.new(
376
+ layout: content.layout,
377
+ freeze: content.freeze,
378
+ skip: content.skip,
379
+ titles: content.titles,
380
+ title_bodies: content.title_bodies,
381
+ subtitles: content.subtitles,
382
+ subtitle_bodies: content.subtitle_bodies,
383
+ bodies: content.bodies,
384
+ images: content.images,
385
+ block_quotes: content.block_quotes,
386
+ tables: content.tables,
387
+ speaker_note: content.speaker_note
388
+ )
389
+
390
+ apply_defaults(slide, content, page_number)
391
+
392
+ if code_block_to_image_command
393
+ convert_code_blocks_to_images(slide, content.code_blocks,
394
+ code_block_to_image_command)
395
+ end
396
+
397
+ slide
398
+ end
399
+
400
+ def apply_defaults(slide, content, page_number)
401
+ return unless frontmatter&.defaults
402
+
403
+ context = build_context(content, page_number)
404
+ frontmatter.defaults.each do |default|
405
+ default.apply_to(slide) if default.evaluate(context)
406
+ end
407
+ end
408
+
409
+ def build_context(content, page_number)
410
+ {
411
+ page: page_number,
412
+ pageTotal: contents.size,
413
+ titles: content.titles,
414
+ subtitles: content.subtitles,
415
+ bodies: content.bodies.map(&:text),
416
+ blockQuotes: content.block_quotes.map(&:text),
417
+ codeBlocks: content.code_blocks,
418
+ images: content.images,
419
+ speakerNote: content.speaker_note,
420
+ headings: {}
421
+ }
422
+ end
423
+
424
+ def convert_code_blocks_to_images(slide, code_blocks, command); end
425
+ end
426
+
427
+ class Content
428
+ attr_accessor :layout, :freeze, :skip, :ignore, :titles, :title_bodies, :subtitles, :subtitle_bodies, :bodies,
429
+ :images, :block_quotes, :tables, :code_blocks, :speaker_note
430
+
431
+ def initialize
432
+ @layout = ''
433
+ @freeze = false
434
+ @skip = false
435
+ @ignore = false
436
+ @titles = []
437
+ @title_bodies = []
438
+ @subtitles = []
439
+ @subtitle_bodies = []
440
+ @bodies = []
441
+ @images = []
442
+ @block_quotes = []
443
+ @tables = []
444
+ @code_blocks = []
445
+ @speaker_note = ''
446
+ end
447
+ end
448
+ end
449
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdeck
4
+ class Paragraph
5
+ BULLET_NONE = ''
6
+ BULLET_DASH = '-'
7
+ BULLET_NUMBERED = '1'
8
+
9
+ attr_accessor :fragments, :bullet, :nesting
10
+
11
+ def initialize(fragments: [], bullet: BULLET_NONE, nesting: 0)
12
+ @fragments = fragments
13
+ @bullet = bullet
14
+ @nesting = nesting
15
+ end
16
+
17
+ def ==(other)
18
+ return false unless other.is_a?(Paragraph)
19
+
20
+ bullet == other.bullet &&
21
+ nesting == other.nesting &&
22
+ fragments == other.fragments
23
+ end
24
+
25
+ def text
26
+ fragments.map(&:value).join
27
+ end
28
+
29
+ def empty?
30
+ fragments.empty? || fragments.all?(&:empty?)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdeck
4
+ class Presentation
5
+ LAYOUT_STYLE = 'style'
6
+
7
+ attr_reader :id, :client
8
+
9
+ def initialize(id, client:)
10
+ @id = id
11
+ @client = client
12
+ @presentation = nil
13
+ @styles = {}
14
+ @fresh = false
15
+ end
16
+
17
+ def refresh
18
+ return if @fresh
19
+
20
+ @presentation = client.slides_service.get_presentation(id)
21
+ load_styles
22
+ @fresh = true
23
+ end
24
+
25
+ def slides_service
26
+ client.slides_service
27
+ end
28
+
29
+ def drive_service
30
+ client.drive_service
31
+ end
32
+
33
+ def apply(slides, pages: nil)
34
+ raise NotImplementedError, 'Presentation#apply will be implemented in Apply class'
35
+ end
36
+
37
+ def list_layouts
38
+ refresh
39
+ @presentation.layouts.map { |l| l.layout_properties.display_name }
40
+ end
41
+
42
+ def create_slide(index, layout_name)
43
+ refresh
44
+ layout = find_layout(layout_name)
45
+
46
+ unless layout
47
+ warn "Layout '#{layout_name}' not found, using first available layout"
48
+ layout = @presentation.layouts&.first
49
+ raise Error, 'No layouts available in presentation' unless layout
50
+ end
51
+
52
+ requests = [
53
+ {
54
+ create_slide: {
55
+ object_id_prop: generate_object_id,
56
+ insertion_index: index,
57
+ slide_layout_reference: {
58
+ layout_id: layout.object_id_prop
59
+ }
60
+ }
61
+ }
62
+ ]
63
+
64
+ batch_update(requests)
65
+ @fresh = false
66
+ end
67
+
68
+ def delete_slides(indices)
69
+ return if indices.empty?
70
+
71
+ refresh
72
+ slide_ids = indices.map { |i| @presentation.slides[i].object_id_prop }
73
+
74
+ requests = slide_ids.map do |slide_id|
75
+ {
76
+ delete_object: {
77
+ object_id_prop: slide_id
78
+ }
79
+ }
80
+ end
81
+
82
+ batch_update(requests)
83
+ @fresh = false
84
+ end
85
+
86
+ def move_slide(from_index, to_index)
87
+ refresh
88
+ slide_id = @presentation.slides[from_index].object_id_prop
89
+
90
+ requests = [
91
+ {
92
+ update_slides_position: {
93
+ slide_object_ids: [slide_id],
94
+ insertion_index: to_index
95
+ }
96
+ }
97
+ ]
98
+
99
+ batch_update(requests)
100
+ @fresh = false
101
+ end
102
+
103
+ def update_title(title)
104
+ drive_service.update_file(
105
+ id,
106
+ Google::Apis::DriveV3::File.new(name: title),
107
+ supports_all_drives: true
108
+ )
109
+ end
110
+
111
+ def export_pdf
112
+ drive_service.export_file(id, 'application/pdf')
113
+ end
114
+
115
+ def self.create(client:, folder_id: nil)
116
+ presentation = client.slides_service.create_presentation(
117
+ Google::Apis::SlidesV1::Presentation.new(title: 'Untitled Presentation')
118
+ )
119
+
120
+ if folder_id
121
+ client.drive_service.update_file(
122
+ presentation.presentation_id,
123
+ Google::Apis::DriveV3::File.new,
124
+ add_parents: folder_id,
125
+ supports_all_drives: true
126
+ )
127
+ end
128
+
129
+ new(presentation.presentation_id, client: client)
130
+ end
131
+
132
+ def self.create_from(base_id, client:, folder_id: nil)
133
+ copied = client.drive_service.copy_file(
134
+ base_id,
135
+ Google::Apis::DriveV3::File.new(name: 'Copy of Presentation'),
136
+ supports_all_drives: true
137
+ )
138
+
139
+ if folder_id
140
+ client.drive_service.update_file(
141
+ copied.id,
142
+ Google::Apis::DriveV3::File.new,
143
+ add_parents: folder_id,
144
+ remove_parents: copied.parents&.join(','),
145
+ supports_all_drives: true
146
+ )
147
+ end
148
+
149
+ new(copied.id, client: client)
150
+ end
151
+
152
+ def self.list(client)
153
+ query = "mimeType='application/vnd.google-apps.presentation'"
154
+ result = client.drive_service.list_files(
155
+ q: query,
156
+ order_by: 'modifiedTime desc',
157
+ page_size: 100,
158
+ supports_all_drives: true,
159
+ include_items_from_all_drives: true
160
+ )
161
+
162
+ result.files.map do |file|
163
+ OpenStruct.new(id: file.id, title: file.name)
164
+ end
165
+ end
166
+
167
+ def current_slides
168
+ refresh
169
+ require_relative 'slide_extractor'
170
+ extractor = SlideExtractor.new(@presentation)
171
+ extractor.extract_slides
172
+ end
173
+
174
+ def batch_update(requests)
175
+ return if requests.empty?
176
+
177
+ require_relative 'retry_handler'
178
+
179
+ requests.each_slice(1000) do |batch|
180
+ RetryHandler.with_retry do
181
+ req = Google::Apis::SlidesV1::BatchUpdatePresentationRequest.new(requests: batch)
182
+ slides_service.batch_update_presentation(id, req)
183
+ end
184
+ end
185
+
186
+ @fresh = false
187
+ end
188
+
189
+ def find_layout(name)
190
+ return nil unless @presentation&.layouts
191
+
192
+ @presentation.layouts.find do |layout|
193
+ layout.layout_properties&.display_name == name
194
+ end
195
+ end
196
+
197
+ private
198
+
199
+ def load_styles
200
+ @styles = {}
201
+ return unless @presentation.layouts
202
+
203
+ style_layout = @presentation.layouts.find do |layout|
204
+ layout.layout_properties.display_name == LAYOUT_STYLE
205
+ end
206
+
207
+ nil unless style_layout
208
+ end
209
+
210
+ def generate_object_id
211
+ "obj_#{Time.now.to_i}_#{rand(100_000)}"
212
+ end
213
+ end
214
+ end
215
+
216
+ require 'ostruct'