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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +290 -0
- data/exe/rdeck +9 -0
- data/lib/rdeck/action_generator.rb +179 -0
- data/lib/rdeck/apply.rb +171 -0
- data/lib/rdeck/block_quote.rb +27 -0
- data/lib/rdeck/body.rb +25 -0
- data/lib/rdeck/cli.rb +361 -0
- data/lib/rdeck/client.rb +128 -0
- data/lib/rdeck/config.rb +97 -0
- data/lib/rdeck/fragment.rb +34 -0
- data/lib/rdeck/hungarian.rb +215 -0
- data/lib/rdeck/image.rb +100 -0
- data/lib/rdeck/image_uploader.rb +75 -0
- data/lib/rdeck/markdown/cel_evaluator.rb +109 -0
- data/lib/rdeck/markdown/frontmatter.rb +75 -0
- data/lib/rdeck/markdown/parser.rb +449 -0
- data/lib/rdeck/paragraph.rb +33 -0
- data/lib/rdeck/presentation.rb +216 -0
- data/lib/rdeck/request_builder.rb +416 -0
- data/lib/rdeck/retry_handler.rb +58 -0
- data/lib/rdeck/slide.rb +64 -0
- data/lib/rdeck/slide_extractor.rb +211 -0
- data/lib/rdeck/table.rb +61 -0
- data/lib/rdeck/version.rb +5 -0
- data/lib/rdeck.rb +28 -0
- metadata +185 -0
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
|
data/lib/rdeck/client.rb
ADDED
|
@@ -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
|
data/lib/rdeck/config.rb
ADDED
|
@@ -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
|