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.
data/lib/rdeck/body.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdeck
4
+ class Body
5
+ attr_accessor :paragraphs
6
+
7
+ def initialize(paragraphs: [])
8
+ @paragraphs = paragraphs
9
+ end
10
+
11
+ def ==(other)
12
+ return false unless other.is_a?(Body)
13
+
14
+ paragraphs == other.paragraphs
15
+ end
16
+
17
+ def text
18
+ paragraphs.map(&:text).join("\n")
19
+ end
20
+
21
+ def empty?
22
+ paragraphs.empty? || paragraphs.all?(&:empty?)
23
+ end
24
+ end
25
+ end
data/lib/rdeck/cli.rb ADDED
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Rdeck
6
+ class CLI
7
+ def self.start(argv)
8
+ new.run(argv)
9
+ end
10
+
11
+ def run(argv)
12
+ return show_help if argv.empty?
13
+
14
+ command = argv.shift
15
+ @options = { profile: nil }
16
+
17
+ case command
18
+ when 'apply'
19
+ run_apply(argv)
20
+ when 'new'
21
+ run_new(argv)
22
+ when 'ls'
23
+ run_ls(argv)
24
+ when 'ls-layouts'
25
+ run_ls_layouts(argv)
26
+ when 'open'
27
+ run_open(argv)
28
+ when 'export'
29
+ run_export(argv)
30
+ when 'doctor'
31
+ run_doctor(argv)
32
+ when 'version', '-v', '--version'
33
+ run_version
34
+ when 'help', '-h', '--help'
35
+ show_help
36
+ else
37
+ warn "Unknown command: #{command}"
38
+ warn "Run 'rdeck help' for usage information"
39
+ exit 1
40
+ end
41
+ rescue StandardError => e
42
+ handle_error(e)
43
+ end
44
+
45
+ private
46
+
47
+ def run_apply(argv)
48
+ parser = OptionParser.new do |opts|
49
+ opts.banner = 'Usage: rdeck apply DECK_FILE [options]'
50
+ opts.on('--profile PROFILE', 'Profile name') { |v| @options[:profile] = v }
51
+ opts.on('-i', '--presentation-id ID', 'Presentation ID') { |v| @options[:presentation_id] = v }
52
+ opts.on('-t', '--title TITLE', 'Presentation title') { |v| @options[:title] = v }
53
+ opts.on('-p', '--page PAGES', "Pages to apply (e.g., '1,3-5')") { |v| @options[:page] = v }
54
+ opts.on('-c', '--code-block-to-image-command CMD', 'Command to convert code blocks to images') do |v|
55
+ @options[:code_block_to_image_command] = v
56
+ end
57
+ opts.on('--folder-id ID', 'Folder ID for temporary images') { |v| @options[:folder_id] = v }
58
+ opts.on('-w', '--watch', 'Watch file for changes') { @options[:watch] = true }
59
+ opts.on('-v', '--verbose', 'Verbose output') { @options[:verbose] = true }
60
+ end
61
+
62
+ parser.parse!(argv)
63
+
64
+ if argv.empty?
65
+ warn 'Error: DECK_FILE is required'
66
+ warn parser.help
67
+ exit 1
68
+ end
69
+
70
+ deck_file = argv.first
71
+
72
+ config = Config.load(@options[:profile])
73
+ parser = Markdown::Parser.new(File.dirname(deck_file))
74
+ md = parser.parse_file(deck_file, config)
75
+
76
+ presentation_id = @options[:presentation_id] || md.frontmatter&.presentation_id
77
+ raise Error, 'Presentation ID is required (use -i or specify in frontmatter)' unless presentation_id
78
+
79
+ client = Client.new(profile: @options[:profile])
80
+ presentation = Presentation.new(presentation_id, client: client)
81
+
82
+ if @options[:watch]
83
+ watch_and_apply(deck_file, config, presentation)
84
+ else
85
+ slides = md.to_slides(@options[:code_block_to_image_command])
86
+ pages = parse_pages(@options[:page], slides.size)
87
+ apply_instance = Apply.new(presentation)
88
+ apply_instance.apply(slides, pages: pages)
89
+ puts "Applied to #{presentation_id}"
90
+ end
91
+ end
92
+
93
+ def run_new(argv)
94
+ parser = OptionParser.new do |opts|
95
+ opts.banner = 'Usage: rdeck new [MARKDOWN_FILE] [options]'
96
+ opts.on('--profile PROFILE', 'Profile name') { |v| @options[:profile] = v }
97
+ opts.on('-t', '--title TITLE', 'Presentation title') { |v| @options[:title] = v }
98
+ opts.on('-b', '--base ID', 'Base presentation ID to copy from') { |v| @options[:base] = v }
99
+ opts.on('--folder-id ID', 'Folder ID to create presentation in') { |v| @options[:folder_id] = v }
100
+ end
101
+
102
+ parser.parse!(argv)
103
+
104
+ markdown_file = argv.first
105
+
106
+ config = Config.load(@options[:profile])
107
+ client = Client.new(profile: @options[:profile])
108
+
109
+ base_id = @options[:base] || config.base_presentation_id
110
+ folder_id = @options[:folder_id] || config.folder_id
111
+
112
+ presentation = if base_id
113
+ Presentation.create_from(base_id, client: client, folder_id: folder_id)
114
+ else
115
+ Presentation.create(client: client, folder_id: folder_id)
116
+ end
117
+
118
+ presentation.update_title(@options[:title]) if @options[:title]
119
+
120
+ if markdown_file
121
+ title = @options[:title] || 'Untitled Presentation'
122
+ Markdown::Frontmatter.apply_to_file(markdown_file, title, presentation.id)
123
+ warn "Applied frontmatter to #{markdown_file}"
124
+ end
125
+
126
+ puts presentation.id
127
+ end
128
+
129
+ def run_ls(argv)
130
+ parser = OptionParser.new do |opts|
131
+ opts.banner = 'Usage: rdeck ls [options]'
132
+ opts.on('--profile PROFILE', 'Profile name') { |v| @options[:profile] = v }
133
+ end
134
+
135
+ parser.parse!(argv)
136
+
137
+ client = Client.new(profile: @options[:profile])
138
+ Presentation.list(client).each do |pres|
139
+ puts "#{pres.id}\t#{pres.title}"
140
+ end
141
+ end
142
+
143
+ def run_ls_layouts(argv)
144
+ parser = OptionParser.new do |opts|
145
+ opts.banner = 'Usage: rdeck ls-layouts DECK_FILE_OR_ID [options]'
146
+ opts.on('--profile PROFILE', 'Profile name') { |v| @options[:profile] = v }
147
+ opts.on('-i', '--presentation-id ID', 'Presentation ID') { |v| @options[:presentation_id] = v }
148
+ end
149
+
150
+ parser.parse!(argv)
151
+
152
+ deck_file_or_id = argv.first
153
+ presentation_id = resolve_presentation_id(deck_file_or_id, @options[:presentation_id])
154
+ raise Error, 'Presentation ID is required' unless presentation_id
155
+
156
+ client = Client.new(profile: @options[:profile])
157
+ presentation = Presentation.new(presentation_id, client: client)
158
+
159
+ presentation.list_layouts.each { |layout| puts layout }
160
+ end
161
+
162
+ def run_open(argv)
163
+ parser = OptionParser.new do |opts|
164
+ opts.banner = 'Usage: rdeck open DECK_FILE_OR_ID [options]'
165
+ opts.on('--profile PROFILE', 'Profile name') { |v| @options[:profile] = v }
166
+ opts.on('-i', '--presentation-id ID', 'Presentation ID') { |v| @options[:presentation_id] = v }
167
+ end
168
+
169
+ parser.parse!(argv)
170
+
171
+ deck_file_or_id = argv.first
172
+ presentation_id = resolve_presentation_id(deck_file_or_id, @options[:presentation_id])
173
+ raise Error, 'Presentation ID is required' unless presentation_id
174
+
175
+ url = "https://docs.google.com/presentation/d/#{presentation_id}/"
176
+ puts url
177
+
178
+ case RUBY_PLATFORM
179
+ when /darwin/
180
+ system("open '#{url}'")
181
+ when /linux/
182
+ system("xdg-open '#{url}'")
183
+ when /mswin|mingw|cygwin/
184
+ system("start '#{url}'")
185
+ end
186
+ end
187
+
188
+ def run_export(argv)
189
+ parser = OptionParser.new do |opts|
190
+ opts.banner = 'Usage: rdeck export DECK_FILE_OR_ID [options]'
191
+ opts.on('--profile PROFILE', 'Profile name') { |v| @options[:profile] = v }
192
+ opts.on('-i', '--presentation-id ID', 'Presentation ID') { |v| @options[:presentation_id] = v }
193
+ opts.on('-o', '--out FILE', 'Output file path') { |v| @options[:out] = v }
194
+ end
195
+
196
+ parser.parse!(argv)
197
+
198
+ deck_file_or_id = argv.first
199
+ presentation_id = resolve_presentation_id(deck_file_or_id, @options[:presentation_id])
200
+ raise Error, 'Presentation ID is required' unless presentation_id
201
+
202
+ client = Client.new(profile: @options[:profile])
203
+ presentation = Presentation.new(presentation_id, client: client)
204
+
205
+ output = @options[:out] || begin
206
+ base = deck_file_or_id && File.exist?(deck_file_or_id) ? File.basename(deck_file_or_id, '.*') : 'deck'
207
+ "#{base}.pdf"
208
+ end
209
+
210
+ File.binwrite(output, presentation.export_pdf)
211
+ puts "Exported to #{output}"
212
+ end
213
+
214
+ def run_doctor(argv)
215
+ parser = OptionParser.new do |opts|
216
+ opts.banner = 'Usage: rdeck doctor [options]'
217
+ opts.on('--profile PROFILE', 'Profile name') { |v| @options[:profile] = v }
218
+ end
219
+
220
+ parser.parse!(argv)
221
+
222
+ puts 'Rdeck Doctor'
223
+ puts '=' * 50
224
+
225
+ puts "\nRuby version: #{RUBY_VERSION}"
226
+ if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.1.0')
227
+ puts ' ✓ Ruby version OK'
228
+ else
229
+ puts ' ✗ Ruby 3.1.0 or higher required'
230
+ end
231
+
232
+ puts "\nConfiguration:"
233
+ config_path = Config.find_config_path(@options[:profile])
234
+ if config_path
235
+ puts " ✓ Config found at: #{config_path}"
236
+ else
237
+ puts ' ✗ No config file found'
238
+ puts ' Expected locations:'
239
+ puts " - #{File.join(Config.xdg_config_home, 'rdeck', 'config.yml')}"
240
+ puts " - #{File.join(Config.xdg_config_home, 'rdeck', 'config.yaml')}"
241
+ end
242
+
243
+ puts "\nCredentials:"
244
+ client = Client.new(profile: @options[:profile])
245
+ credentials_path = client.send(:credentials_file_path)
246
+ if File.exist?(credentials_path)
247
+ puts " ✓ Credentials found at: #{credentials_path}"
248
+ else
249
+ puts " ✗ No credentials file found at: #{credentials_path}"
250
+ puts ' Please download OAuth2 credentials from Google Cloud Console'
251
+ end
252
+
253
+ puts "\nAPI Connection:"
254
+ begin
255
+ presentations = Presentation.list(client)
256
+ puts ' ✓ Successfully connected to Google APIs'
257
+ puts " Found #{presentations.size} presentations"
258
+ rescue StandardError => e
259
+ puts ' ✗ Failed to connect to Google APIs'
260
+ puts " Error: #{e.message}"
261
+ end
262
+ end
263
+
264
+ def run_version
265
+ puts "rdeck version #{Rdeck::VERSION}"
266
+ end
267
+
268
+ def show_help
269
+ puts <<~HELP
270
+ rdeck - Convert Markdown presentations to Google Slides
271
+
272
+ Usage:
273
+ rdeck COMMAND [options]
274
+
275
+ Commands:
276
+ apply DECK_FILE Apply deck written in markdown to Google Slides
277
+ new [MARKDOWN_FILE] Create new presentation
278
+ ls List Google Slides presentations
279
+ ls-layouts DECK_FILE_OR_ID List layouts of Google Slides presentation
280
+ open DECK_FILE_OR_ID Open Google Slides presentation in browser
281
+ export DECK_FILE_OR_ID Export deck to PDF
282
+ doctor Check rdeck environment and configuration
283
+ version Show version
284
+ help Show this help message
285
+
286
+ Global Options:
287
+ --profile PROFILE Profile name
288
+
289
+ Run 'rdeck COMMAND --help' for more information on a command.
290
+ HELP
291
+ end
292
+
293
+ def resolve_presentation_id(file_or_id, option_id)
294
+ return option_id if option_id
295
+
296
+ if file_or_id && File.exist?(file_or_id)
297
+ parser = Markdown::Parser.new(File.dirname(file_or_id))
298
+ md = parser.parse_file(file_or_id)
299
+ return md.frontmatter&.presentation_id
300
+ end
301
+
302
+ file_or_id
303
+ end
304
+
305
+ def parse_pages(page_str, total)
306
+ return (1..total).to_a unless page_str
307
+
308
+ pages = []
309
+ page_str.split(',').each do |part|
310
+ if part.include?('-')
311
+ range_parts = part.split('-', -1)
312
+ start_page = range_parts[0].to_s.empty? ? 1 : range_parts[0].to_i
313
+ end_page = range_parts[1].to_s.empty? ? total : range_parts[1].to_i
314
+ pages += (start_page..end_page).to_a
315
+ else
316
+ pages << part.to_i
317
+ end
318
+ end
319
+ pages.uniq.sort
320
+ end
321
+
322
+ def watch_and_apply(deck_file, config, presentation)
323
+ require 'listen'
324
+
325
+ parser = Markdown::Parser.new(File.dirname(deck_file))
326
+
327
+ listener = Listen.to(File.dirname(deck_file),
328
+ only: /#{Regexp.escape(File.basename(deck_file))}$/) do |modified, added, _removed|
329
+ if modified.any? || added.any?
330
+ begin
331
+ md = parser.parse_file(deck_file, config)
332
+ slides = md.to_slides(@options[:code_block_to_image_command])
333
+ apply_instance = Apply.new(presentation)
334
+ apply_instance.apply(slides)
335
+ puts "Applied changes at #{Time.now}"
336
+ rescue StandardError => e
337
+ warn "Error applying changes: #{e.message}"
338
+ end
339
+ end
340
+ end
341
+
342
+ puts "Watching #{deck_file} for changes..."
343
+ puts 'Press Ctrl+C to stop'
344
+ listener.start
345
+ sleep
346
+ end
347
+
348
+ def handle_error(error)
349
+ case error
350
+ when Rdeck::Error
351
+ warn "Error: #{error.message}"
352
+ when Google::Apis::Error
353
+ warn "Google API Error: #{error.message}"
354
+ else
355
+ warn "Unexpected error: #{error.class}: #{error.message}"
356
+ warn error.backtrace.join("\n") if @options&.dig(:verbose)
357
+ end
358
+ exit 1
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'google/apis/slides_v1'
4
+ require 'google/apis/drive_v3'
5
+ require 'googleauth'
6
+ require 'googleauth/stores/file_token_store'
7
+ require 'fileutils'
8
+
9
+ module Rdeck
10
+ class Client
11
+ SCOPES = [
12
+ Google::Apis::SlidesV1::AUTH_PRESENTATIONS,
13
+ Google::Apis::DriveV3::AUTH_DRIVE
14
+ ].freeze
15
+
16
+ attr_reader :profile
17
+
18
+ def initialize(profile: nil)
19
+ @profile = profile
20
+ @slides_service = nil
21
+ @drive_service = nil
22
+ end
23
+
24
+ def slides_service
25
+ @slides_service ||= begin
26
+ service = Google::Apis::SlidesV1::SlidesService.new
27
+ service.authorization = authorization
28
+ service
29
+ end
30
+ end
31
+
32
+ def drive_service
33
+ @drive_service ||= begin
34
+ service = Google::Apis::DriveV3::DriveService.new
35
+ service.authorization = authorization
36
+ service
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def authorization
43
+ @authorization ||= if ENV['DECK_SERVICE_ACCOUNT_KEY'] || ENV['RDECK_SERVICE_ACCOUNT_KEY']
44
+ service_account_auth
45
+ elsif ENV['DECK_ENABLE_ADC'] || ENV['RDECK_ENABLE_ADC']
46
+ application_default_credentials
47
+ elsif ENV['DECK_ACCESS_TOKEN'] || ENV['RDECK_ACCESS_TOKEN']
48
+ access_token_auth
49
+ else
50
+ oauth2_auth
51
+ end
52
+ end
53
+
54
+ def service_account_auth
55
+ key_content = ENV['DECK_SERVICE_ACCOUNT_KEY'] || ENV.fetch('RDECK_SERVICE_ACCOUNT_KEY', nil)
56
+ key_io = StringIO.new(key_content)
57
+ authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
58
+ json_key_io: key_io,
59
+ scope: SCOPES
60
+ )
61
+ authorizer.fetch_access_token!
62
+ authorizer
63
+ end
64
+
65
+ def application_default_credentials
66
+ Google::Auth.get_application_default(SCOPES)
67
+ end
68
+
69
+ def access_token_auth
70
+ token = ENV['DECK_ACCESS_TOKEN'] || ENV.fetch('RDECK_ACCESS_TOKEN', nil)
71
+ Signet::OAuth2::Client.new(access_token: token)
72
+ end
73
+
74
+ def oauth2_auth
75
+ credentials_path = credentials_file_path
76
+ token_path = token_file_path
77
+
78
+ unless File.exist?(credentials_path)
79
+ raise Error, "Credentials file not found at #{credentials_path}. " \
80
+ 'Please download OAuth2 credentials from Google Cloud Console.'
81
+ end
82
+
83
+ client_id = Google::Auth::ClientId.from_file(credentials_path)
84
+ token_store = Google::Auth::Stores::FileTokenStore.new(file: token_path)
85
+ authorizer = Google::Auth::UserAuthorizer.new(client_id, SCOPES, token_store)
86
+
87
+ user_id = 'default'
88
+ credentials = authorizer.get_credentials(user_id)
89
+
90
+ if credentials.nil?
91
+ url = authorizer.get_authorization_url(base_url: 'urn:ietf:wg:oauth:2.0:oob')
92
+ puts 'Open the following URL in your browser and enter the resulting code:'
93
+ puts url
94
+ print 'Code: '
95
+ code = $stdin.gets.chomp
96
+ credentials = authorizer.get_and_store_credentials_from_code(
97
+ user_id: user_id,
98
+ code: code,
99
+ base_url: 'urn:ietf:wg:oauth:2.0:oob'
100
+ )
101
+ end
102
+
103
+ credentials
104
+ end
105
+
106
+ def credentials_file_path
107
+ base = Config.xdg_data_home
108
+ FileUtils.mkdir_p(File.join(base, 'rdeck'))
109
+
110
+ if @profile
111
+ File.join(base, 'rdeck', "credentials-#{@profile}.json")
112
+ else
113
+ File.join(base, 'rdeck', 'credentials.json')
114
+ end
115
+ end
116
+
117
+ def token_file_path
118
+ base = Config.xdg_state_home
119
+ FileUtils.mkdir_p(File.join(base, 'rdeck'))
120
+
121
+ if @profile
122
+ File.join(base, 'rdeck', "token-#{@profile}.json")
123
+ else
124
+ File.join(base, 'rdeck', 'token.json')
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Rdeck
6
+ class Config
7
+ attr_accessor :breaks, :defaults, :code_block_to_image_command, :folder_id, :base_presentation_id
8
+
9
+ def initialize
10
+ @breaks = false
11
+ @defaults = []
12
+ @code_block_to_image_command = nil
13
+ @folder_id = nil
14
+ @base_presentation_id = nil
15
+ end
16
+
17
+ def self.load(profile = nil)
18
+ config = new
19
+ config_path = find_config_path(profile)
20
+ if config_path && File.exist?(config_path)
21
+ yaml = YAML.safe_load_file(config_path, permitted_classes: [Symbol], symbolize_names: true)
22
+ config.apply_yaml(yaml)
23
+ end
24
+ config
25
+ end
26
+
27
+ def self.find_config_path(profile = nil)
28
+ base = xdg_config_home
29
+ suffix = profile ? "-#{profile}" : ''
30
+
31
+ [
32
+ File.join(base, 'rdeck', "config#{suffix}.yml"),
33
+ File.join(base, 'rdeck', "config#{suffix}.yaml")
34
+ ].find { |path| File.exist?(path) }
35
+ end
36
+
37
+ def self.xdg_config_home
38
+ ENV.fetch('XDG_CONFIG_HOME', File.expand_path('~/.config'))
39
+ end
40
+
41
+ def self.xdg_data_home
42
+ ENV.fetch('XDG_DATA_HOME', File.expand_path('~/.local/share'))
43
+ end
44
+
45
+ def self.xdg_state_home
46
+ ENV.fetch('XDG_STATE_HOME', File.expand_path('~/.local/state'))
47
+ end
48
+
49
+ def apply_yaml(yaml)
50
+ @breaks = yaml[:breaks] if yaml.key?(:breaks)
51
+ @defaults = parse_defaults(yaml[:defaults]) if yaml.key?(:defaults)
52
+ @code_block_to_image_command = yaml[:codeBlockToImageCommand] if yaml.key?(:codeBlockToImageCommand)
53
+ @folder_id = yaml[:folderID] if yaml.key?(:folderID)
54
+ @base_presentation_id = yaml[:basePresentationID] if yaml.key?(:basePresentationID)
55
+ end
56
+
57
+ private
58
+
59
+ def parse_defaults(defaults_array)
60
+ return [] unless defaults_array.is_a?(Array)
61
+
62
+ defaults_array.map do |default|
63
+ DefaultCondition.new(default)
64
+ end
65
+ end
66
+ end
67
+
68
+ class DefaultCondition
69
+ attr_accessor :if_condition, :layout, :freeze, :ignore, :skip
70
+
71
+ def initialize(attrs = {})
72
+ @if_condition = fetch_attr(attrs, :if)
73
+ @layout = fetch_attr(attrs, :layout)
74
+ @freeze = fetch_attr(attrs, :freeze)
75
+ @ignore = fetch_attr(attrs, :ignore)
76
+ @skip = fetch_attr(attrs, :skip)
77
+ end
78
+
79
+ def evaluate(context)
80
+ return true unless if_condition
81
+
82
+ Rdeck::Markdown::CELEvaluator.new.evaluate(if_condition, context)
83
+ end
84
+
85
+ def apply_to(slide)
86
+ slide.layout = layout if layout
87
+ slide.freeze = freeze unless freeze.nil?
88
+ slide.skip = skip unless skip.nil?
89
+ end
90
+
91
+ private
92
+
93
+ def fetch_attr(attrs, key)
94
+ attrs.fetch(key) { attrs[key.to_s] }
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdeck
4
+ class Fragment
5
+ attr_accessor :value, :bold, :italic, :link, :code, :style_name
6
+
7
+ def initialize(value: '', bold: false, italic: false, link: '', code: false, style_name: '')
8
+ @value = value
9
+ @bold = bold
10
+ @italic = italic
11
+ @link = link
12
+ @code = code
13
+ @style_name = style_name
14
+ end
15
+
16
+ def ==(other)
17
+ return false unless other.is_a?(Fragment)
18
+
19
+ value == other.value && styles_equal?(other)
20
+ end
21
+
22
+ def styles_equal?(other)
23
+ bold == other.bold &&
24
+ italic == other.italic &&
25
+ link == other.link &&
26
+ code == other.code &&
27
+ style_name == other.style_name
28
+ end
29
+
30
+ def empty?
31
+ value.empty?
32
+ end
33
+ end
34
+ end