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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: da13cdf307d357991ee63bb6b0809c37c72aad4bc535b0e3b2daaccf17af17d7
|
|
4
|
+
data.tar.gz: 224b8810ab80919eb2f84c27046d11bfa2ea71b80e4be8ff0f9c241291546864
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 935940ebb52fb91f3c7fdb731be78e590de4fb307211e4d28a77d2b20277075aed4fae17cad1cfb63deb9fa0a6e9f26a6a398889befb7805a5b2e6f9160f5176
|
|
7
|
+
data.tar.gz: f2fc4b8a1238cf719940dd2f99e4cedd60139e01d179378ab2f11d9a47dde3b862019360821acea77e3966a4fd8aaf17dcf8a42dd202d095839894fbaecfa488
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# Rdeck
|
|
2
|
+
|
|
3
|
+
Rdeck is a Ruby CLI tool that converts Markdown presentations to Google Slides, enabling continuous deck creation with content and design separation.
|
|
4
|
+
|
|
5
|
+
This is a Ruby implementation of the [deck](https://github.com/k1LoW/deck) tool originally written in Go.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Convert Markdown files to Google Slides presentations
|
|
10
|
+
- Support for CommonMark and GitHub Flavored Markdown (GFM)
|
|
11
|
+
- Automatic slide layout detection
|
|
12
|
+
- Support for images, tables, and block quotes
|
|
13
|
+
- Code blocks with syntax highlighting (via external command)
|
|
14
|
+
- Incremental updates - only modify changed slides
|
|
15
|
+
- File watching mode for live updates
|
|
16
|
+
- Custom style support
|
|
17
|
+
- Multiple authentication methods (OAuth2, Service Account, ADC)
|
|
18
|
+
- Profile support for multiple Google accounts
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Add this line to your application's Gemfile:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem 'rdeck'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
And then execute:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
$ bundle install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or install it yourself as:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
$ gem install rdeck
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Prerequisites
|
|
41
|
+
|
|
42
|
+
### Google Cloud Setup
|
|
43
|
+
|
|
44
|
+
1. Create a project in [Google Cloud Console](https://console.cloud.google.com/)
|
|
45
|
+
2. Enable the Google Slides API and Google Drive API
|
|
46
|
+
3. Create OAuth2 credentials or a Service Account
|
|
47
|
+
4. Download the credentials file
|
|
48
|
+
|
|
49
|
+
### System Dependencies
|
|
50
|
+
|
|
51
|
+
- Ruby 3.1.0 or higher
|
|
52
|
+
- libvips (for image processing)
|
|
53
|
+
|
|
54
|
+
On macOS:
|
|
55
|
+
```bash
|
|
56
|
+
brew install vips
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
On Ubuntu/Debian:
|
|
60
|
+
```bash
|
|
61
|
+
sudo apt-get install libvips-dev
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
### Create a New Presentation
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Create a blank presentation
|
|
70
|
+
$ rdeck new
|
|
71
|
+
|
|
72
|
+
# Create with a title and save ID to markdown file
|
|
73
|
+
$ rdeck new deck.md -t "My Presentation"
|
|
74
|
+
|
|
75
|
+
# Create from a base template
|
|
76
|
+
$ rdeck new deck.md -t "My Presentation" -b <base-presentation-id>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Apply Markdown to Slides
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Apply markdown to presentation
|
|
83
|
+
$ rdeck apply deck.md
|
|
84
|
+
|
|
85
|
+
# Specify presentation ID via command line
|
|
86
|
+
$ rdeck apply deck.md -i <presentation-id>
|
|
87
|
+
|
|
88
|
+
# Apply specific pages
|
|
89
|
+
$ rdeck apply deck.md -p 1,3-5
|
|
90
|
+
|
|
91
|
+
# Watch mode - automatically apply on file changes
|
|
92
|
+
$ rdeck apply deck.md -w
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Other Commands
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# List your presentations
|
|
99
|
+
$ rdeck ls
|
|
100
|
+
|
|
101
|
+
# List available layouts
|
|
102
|
+
$ rdeck ls-layouts deck.md
|
|
103
|
+
|
|
104
|
+
# Open presentation in browser
|
|
105
|
+
$ rdeck open deck.md
|
|
106
|
+
|
|
107
|
+
# Export to PDF
|
|
108
|
+
$ rdeck export deck.md -o presentation.pdf
|
|
109
|
+
|
|
110
|
+
# Check environment and configuration
|
|
111
|
+
$ rdeck doctor
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Markdown Format
|
|
115
|
+
|
|
116
|
+
### Basic Structure
|
|
117
|
+
|
|
118
|
+
```markdown
|
|
119
|
+
---
|
|
120
|
+
presentationID: your-presentation-id
|
|
121
|
+
title: My Presentation
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
# First Slide Title
|
|
125
|
+
|
|
126
|
+
This is the content of the first slide.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Second Slide
|
|
131
|
+
|
|
132
|
+
- Bullet point 1
|
|
133
|
+
- Bullet point 2
|
|
134
|
+
- Nested bullet
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
# Slide with Image
|
|
139
|
+
|
|
140
|
+

|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
# Slide with Table
|
|
145
|
+
|
|
146
|
+
| Header 1 | Header 2 |
|
|
147
|
+
|----------|----------|
|
|
148
|
+
| Cell 1 | Cell 2 |
|
|
149
|
+
| Cell 3 | Cell 4 |
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Frontmatter Options
|
|
153
|
+
|
|
154
|
+
```yaml
|
|
155
|
+
---
|
|
156
|
+
presentationID: your-presentation-id # Required for apply
|
|
157
|
+
title: My Presentation
|
|
158
|
+
breaks: true # Treat single line breaks as <br>
|
|
159
|
+
codeBlockToImageCommand: "command to convert code"
|
|
160
|
+
defaults:
|
|
161
|
+
- if: page == 1
|
|
162
|
+
layout: title
|
|
163
|
+
- if: titles.size() == 0
|
|
164
|
+
layout: blank
|
|
165
|
+
---
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Page Configuration
|
|
169
|
+
|
|
170
|
+
Use HTML comments with JSON to configure individual pages:
|
|
171
|
+
|
|
172
|
+
```markdown
|
|
173
|
+
<!-- {"layout": "title-and-body"} -->
|
|
174
|
+
<!-- {"freeze": true} -->
|
|
175
|
+
<!-- {"skip": true} -->
|
|
176
|
+
|
|
177
|
+
# Slide Title
|
|
178
|
+
|
|
179
|
+
Content here
|
|
180
|
+
|
|
181
|
+
<!-- This is a speaker note -->
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Supported Markdown Features
|
|
185
|
+
|
|
186
|
+
- **Headers** (H1-H6): Mapped to title, subtitle, or body based on hierarchy
|
|
187
|
+
- **Bold** and *italic* text
|
|
188
|
+
- [Links](https://example.com)
|
|
189
|
+
- `Inline code`
|
|
190
|
+
- Bullet lists (ordered and unordered)
|
|
191
|
+
- Nested lists
|
|
192
|
+
- > Block quotes
|
|
193
|
+
- Tables
|
|
194
|
+
- Images
|
|
195
|
+
- ~~Strikethrough~~
|
|
196
|
+
- Horizontal rules (for page breaks: `---`)
|
|
197
|
+
|
|
198
|
+
## Configuration
|
|
199
|
+
|
|
200
|
+
Configuration files are located at:
|
|
201
|
+
- `~/.config/rdeck/config.yml`
|
|
202
|
+
- `~/.config/rdeck/config-{profile}.yml` (for profiles)
|
|
203
|
+
|
|
204
|
+
Example configuration:
|
|
205
|
+
|
|
206
|
+
```yaml
|
|
207
|
+
# Base presentation to copy from
|
|
208
|
+
basePresentationID: "abc123..."
|
|
209
|
+
|
|
210
|
+
# Default folder for new presentations
|
|
211
|
+
folderID: "xyz789..."
|
|
212
|
+
|
|
213
|
+
# Default line break behavior
|
|
214
|
+
breaks: true
|
|
215
|
+
|
|
216
|
+
# Command to convert code blocks to images
|
|
217
|
+
codeBlockToImageCommand: "code2img"
|
|
218
|
+
|
|
219
|
+
# Default conditions for layout selection
|
|
220
|
+
defaults:
|
|
221
|
+
- if: page == 1
|
|
222
|
+
layout: title
|
|
223
|
+
- if: titles.size() == 1 && headings[2].size() == 1
|
|
224
|
+
layout: section
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Authentication
|
|
228
|
+
|
|
229
|
+
### OAuth2 (Default)
|
|
230
|
+
|
|
231
|
+
1. Download OAuth2 credentials from Google Cloud Console
|
|
232
|
+
2. Save to `~/.local/share/rdeck/credentials.json`
|
|
233
|
+
3. Run any rdeck command - you'll be prompted to authorize
|
|
234
|
+
|
|
235
|
+
### Service Account
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
export RDECK_SERVICE_ACCOUNT_KEY='{ "type": "service_account", ... }'
|
|
239
|
+
rdeck apply deck.md
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Application Default Credentials
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
export RDECK_ENABLE_ADC=1
|
|
246
|
+
rdeck apply deck.md
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Access Token
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
export RDECK_ACCESS_TOKEN="ya29...."
|
|
253
|
+
rdeck apply deck.md
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Profiles
|
|
257
|
+
|
|
258
|
+
Use profiles to manage multiple Google accounts:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
# Use a specific profile
|
|
262
|
+
rdeck apply deck.md --profile work
|
|
263
|
+
|
|
264
|
+
# Profile files
|
|
265
|
+
~/.config/rdeck/config-work.yml
|
|
266
|
+
~/.local/share/rdeck/credentials-work.json
|
|
267
|
+
~/.local/state/rdeck/token-work.json
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Development
|
|
271
|
+
|
|
272
|
+
After checking out the repo, run:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
bundle install
|
|
276
|
+
bundle exec rake spec # Run tests
|
|
277
|
+
bundle exec rdeck # Run CLI
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Contributing
|
|
281
|
+
|
|
282
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/ydah/rdeck.
|
|
283
|
+
|
|
284
|
+
## License
|
|
285
|
+
|
|
286
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
287
|
+
|
|
288
|
+
## Acknowledgments
|
|
289
|
+
|
|
290
|
+
This project is a Ruby port of [deck](https://github.com/k1LoW/deck) by k1LoW. The original Go implementation provided the specification and inspiration for this Ruby version.
|
data/exe/rdeck
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rdeck
|
|
4
|
+
class ActionGenerator
|
|
5
|
+
Action = Struct.new(:type, :index, :move_to_index, :slide, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
# Similarity scoring constants
|
|
8
|
+
SCORE_EXACT_MATCH = 500
|
|
9
|
+
SCORE_LAYOUT_MATCH = 50
|
|
10
|
+
SCORE_TITLES_MATCH = 80
|
|
11
|
+
SCORE_SUBTITLES_MATCH = 20
|
|
12
|
+
SCORE_BODIES_MATCH = 160
|
|
13
|
+
SCORE_IMAGES_MATCH = 40
|
|
14
|
+
SCORE_BLOCK_QUOTES_MATCH = 30
|
|
15
|
+
SCORE_TABLES_MATCH = 35
|
|
16
|
+
SCORE_SAME_POSITION_WITH_LAYOUT = 8
|
|
17
|
+
SCORE_EARLIER_POSITION_WITH_LAYOUT = 6
|
|
18
|
+
SCORE_LATER_POSITION_WITH_LAYOUT = 4
|
|
19
|
+
SCORE_FALLBACK_WITH_LAYOUT = 2
|
|
20
|
+
SCORE_SAME_POSITION_WITHOUT_LAYOUT = 4
|
|
21
|
+
SCORE_LATER_POSITION_WITHOUT_LAYOUT = 2
|
|
22
|
+
|
|
23
|
+
def generate(before, after)
|
|
24
|
+
before_copy = deep_copy(before)
|
|
25
|
+
after_copy = deep_copy(after)
|
|
26
|
+
|
|
27
|
+
adjusted_before, adjusted_after = adjust_slide_count(before_copy, after_copy)
|
|
28
|
+
|
|
29
|
+
apply_freeze_marks(adjusted_before, adjusted_after)
|
|
30
|
+
|
|
31
|
+
mapping = map_slides(adjusted_before, adjusted_after)
|
|
32
|
+
|
|
33
|
+
apply_delete_marks(adjusted_before, adjusted_after, mapping)
|
|
34
|
+
|
|
35
|
+
actions = []
|
|
36
|
+
|
|
37
|
+
actions += generate_append_actions(adjusted_before)
|
|
38
|
+
|
|
39
|
+
actions += generate_update_actions(adjusted_before, adjusted_after, mapping)
|
|
40
|
+
|
|
41
|
+
actions += generate_delete_actions(adjusted_before, mapping)
|
|
42
|
+
|
|
43
|
+
cleaned_after = remove_delete_marked(adjusted_after)
|
|
44
|
+
actions += generate_move_actions(adjusted_before, cleaned_after, mapping)
|
|
45
|
+
|
|
46
|
+
actions
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def deep_copy(slides)
|
|
52
|
+
slides.map(&:dup)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def adjust_slide_count(before, after)
|
|
56
|
+
size_diff = after.size - before.size
|
|
57
|
+
|
|
58
|
+
if size_diff.positive?
|
|
59
|
+
size_diff.times do
|
|
60
|
+
slide = Slide.new
|
|
61
|
+
slide.new_slide = true
|
|
62
|
+
before << slide
|
|
63
|
+
end
|
|
64
|
+
elsif size_diff.negative?
|
|
65
|
+
(-size_diff).times do
|
|
66
|
+
after << Slide.new
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
[before, after]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def apply_freeze_marks(before, after)
|
|
74
|
+
before.each_with_index do |slide, i|
|
|
75
|
+
after[i].freeze = true if slide.freeze
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def map_slides(before, after)
|
|
80
|
+
n = before.size
|
|
81
|
+
return {} if n.zero?
|
|
82
|
+
|
|
83
|
+
similarity_matrix = Array.new(n) { Array.new(n, 0) }
|
|
84
|
+
n.times do |i|
|
|
85
|
+
n.times do |j|
|
|
86
|
+
similarity_matrix[i][j] = calculate_similarity(before[i], after[j], i, j)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
require_relative 'hungarian'
|
|
91
|
+
hungarian = Hungarian.new(similarity_matrix)
|
|
92
|
+
hungarian.solve
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def calculate_similarity(slide1, slide2, idx1, idx2)
|
|
96
|
+
return SCORE_EXACT_MATCH if slide1 == slide2
|
|
97
|
+
|
|
98
|
+
score = 0
|
|
99
|
+
|
|
100
|
+
if slide1.layout == slide2.layout && !slide1.layout.empty?
|
|
101
|
+
score += SCORE_LAYOUT_MATCH
|
|
102
|
+
|
|
103
|
+
score += SCORE_TITLES_MATCH if slide1.titles == slide2.titles && !slide1.titles.empty?
|
|
104
|
+
score += SCORE_SUBTITLES_MATCH if slide1.subtitles == slide2.subtitles && !slide1.subtitles.empty?
|
|
105
|
+
score += SCORE_BODIES_MATCH if slide1.bodies == slide2.bodies
|
|
106
|
+
score += SCORE_IMAGES_MATCH if images_equivalent?(slide1.images, slide2.images)
|
|
107
|
+
score += SCORE_BLOCK_QUOTES_MATCH if slide1.block_quotes == slide2.block_quotes
|
|
108
|
+
score += SCORE_TABLES_MATCH if slide1.tables == slide2.tables
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
if slide1.layout == slide2.layout && !slide1.layout.empty?
|
|
112
|
+
score += if idx1 == idx2
|
|
113
|
+
SCORE_SAME_POSITION_WITH_LAYOUT
|
|
114
|
+
elsif idx2 < idx1
|
|
115
|
+
SCORE_EARLIER_POSITION_WITH_LAYOUT
|
|
116
|
+
elsif idx1 < idx2
|
|
117
|
+
SCORE_LATER_POSITION_WITH_LAYOUT
|
|
118
|
+
else
|
|
119
|
+
SCORE_FALLBACK_WITH_LAYOUT
|
|
120
|
+
end
|
|
121
|
+
elsif idx1 == idx2
|
|
122
|
+
score += SCORE_SAME_POSITION_WITHOUT_LAYOUT
|
|
123
|
+
elsif idx1 < idx2
|
|
124
|
+
score += SCORE_LATER_POSITION_WITHOUT_LAYOUT
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
score
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def images_equivalent?(images1, images2)
|
|
131
|
+
return true if images1.size != images2.size
|
|
132
|
+
|
|
133
|
+
images1.zip(images2).all? { |img1, img2| img1&.equivalent?(img2) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def apply_delete_marks(before, after, mapping)
|
|
137
|
+
mapping.each do |before_idx, after_idx|
|
|
138
|
+
before[before_idx].delete_slide = if after[after_idx].freeze
|
|
139
|
+
false
|
|
140
|
+
elsif before[before_idx].new_slide
|
|
141
|
+
false
|
|
142
|
+
else
|
|
143
|
+
after[after_idx].empty?
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def generate_append_actions(before)
|
|
149
|
+
before.select { |s| s.new_slide && !s.delete_slide }
|
|
150
|
+
.map { |s| Action.new(type: :append, slide: s) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def generate_update_actions(before, after, mapping)
|
|
154
|
+
actions = []
|
|
155
|
+
|
|
156
|
+
mapping.each do |before_idx, after_idx|
|
|
157
|
+
next if before[before_idx].delete_slide
|
|
158
|
+
next if after[after_idx].freeze
|
|
159
|
+
|
|
160
|
+
actions << Action.new(type: :update, index: before_idx, slide: after[after_idx])
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
actions
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def generate_delete_actions(before, _mapping)
|
|
167
|
+
before.each_with_index.select { |slide, _| slide.delete_slide }
|
|
168
|
+
.map { |_, idx| Action.new(type: :delete, index: idx) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def generate_move_actions(_before, _after, _mapping)
|
|
172
|
+
[]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def remove_delete_marked(slides)
|
|
176
|
+
slides.reject(&:delete_slide)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
data/lib/rdeck/apply.rb
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rdeck
|
|
4
|
+
class Apply
|
|
5
|
+
attr_reader :presentation
|
|
6
|
+
|
|
7
|
+
def initialize(presentation)
|
|
8
|
+
@presentation = presentation
|
|
9
|
+
@request_builder = nil
|
|
10
|
+
@image_uploader = nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def apply(slides, pages: nil, folder_id: nil)
|
|
14
|
+
pages ||= (1..slides.size).to_a
|
|
15
|
+
|
|
16
|
+
presentation.refresh
|
|
17
|
+
initialize_helpers(folder_id)
|
|
18
|
+
|
|
19
|
+
before = presentation.current_slides
|
|
20
|
+
after = build_after_slides(before, slides, pages)
|
|
21
|
+
|
|
22
|
+
generator = ActionGenerator.new
|
|
23
|
+
actions = generator.generate(before, after)
|
|
24
|
+
|
|
25
|
+
preload_images(actions)
|
|
26
|
+
|
|
27
|
+
execute_actions(actions)
|
|
28
|
+
|
|
29
|
+
presentation.refresh
|
|
30
|
+
ensure
|
|
31
|
+
@image_uploader&.cleanup
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def initialize_helpers(folder_id)
|
|
37
|
+
require_relative 'request_builder'
|
|
38
|
+
require_relative 'image_uploader'
|
|
39
|
+
@request_builder = RequestBuilder.new(presentation)
|
|
40
|
+
@image_uploader = ImageUploader.new(presentation.drive_service, folder_id:)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def slide_layout_name(slide)
|
|
44
|
+
slide&.slide_properties&.layout_object_id&.then do |layout_id|
|
|
45
|
+
presentation_data = presentation.instance_variable_get(:@presentation)
|
|
46
|
+
presentation_data&.layouts&.find { _1.object_id_prop == layout_id }&.layout_properties&.display_name
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build_after_slides(before, slides, pages)
|
|
51
|
+
before.dup.tap do |after|
|
|
52
|
+
pages.each_with_index do |page, idx|
|
|
53
|
+
slide = slides[page - 1]
|
|
54
|
+
next unless slide
|
|
55
|
+
|
|
56
|
+
idx < after.size ? after[idx] = slide : after << slide
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def preload_images(actions)
|
|
62
|
+
actions
|
|
63
|
+
.filter { %i[append update].include?(_1.type) && _1.slide }
|
|
64
|
+
.flat_map { _1.slide.images || [] }
|
|
65
|
+
.filter(&:from_markdown)
|
|
66
|
+
.each { @image_uploader.upload(_1) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def execute_actions(actions)
|
|
70
|
+
state = ExecutionState.new(presentation.current_slides.size)
|
|
71
|
+
|
|
72
|
+
actions.each do |action|
|
|
73
|
+
case action
|
|
74
|
+
in { type: :append }
|
|
75
|
+
handle_append(action, state)
|
|
76
|
+
in { type: :update }
|
|
77
|
+
handle_update(action, state)
|
|
78
|
+
in { type: :delete }
|
|
79
|
+
state.delete_indices << action.index
|
|
80
|
+
in { type: :move }
|
|
81
|
+
handle_move(action, state)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
presentation.batch_update(state.all_requests)
|
|
86
|
+
|
|
87
|
+
presentation.delete_slides(state.delete_indices.sort.reverse) unless state.delete_indices.empty?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def handle_append(action, state)
|
|
91
|
+
layout = action.slide.layout.empty? ? 'BLANK' : action.slide.layout
|
|
92
|
+
index = state.current_slide_count + state.append_count
|
|
93
|
+
|
|
94
|
+
presentation.create_slide(index, layout)
|
|
95
|
+
|
|
96
|
+
presentation.refresh
|
|
97
|
+
new_slide = current_slide_at(index)
|
|
98
|
+
requests = @request_builder.build_slide_update_requests(index, action.slide, new_slide)
|
|
99
|
+
state.appending_requests.concat(requests.compact)
|
|
100
|
+
|
|
101
|
+
state.append_count += 1
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def handle_update(action, state)
|
|
105
|
+
presentation.refresh
|
|
106
|
+
existing_slide = current_slide_at(action.index)
|
|
107
|
+
|
|
108
|
+
current_layout = slide_layout_name(existing_slide)
|
|
109
|
+
requested_layout = action.slide.layout
|
|
110
|
+
|
|
111
|
+
if layout_change_needed?(current_layout, requested_layout)
|
|
112
|
+
recreate_slide_with_new_layout(action, state, current_layout, requested_layout)
|
|
113
|
+
else
|
|
114
|
+
requests = @request_builder.build_slide_update_requests(action.index, action.slide, existing_slide)
|
|
115
|
+
state.update_requests.concat(requests.compact)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def handle_move(action, state)
|
|
120
|
+
flush_pending_requests(state)
|
|
121
|
+
presentation.move_slide(action.index, action.move_to_index)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def layout_change_needed?(current, requested)
|
|
125
|
+
requested && !requested.empty? && current != requested
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def recreate_slide_with_new_layout(action, state, current_layout, requested_layout)
|
|
129
|
+
warn "Recreating slide #{action.index} to change layout from '#{current_layout}' to '#{requested_layout}'"
|
|
130
|
+
|
|
131
|
+
flush_pending_requests(state)
|
|
132
|
+
|
|
133
|
+
presentation.delete_slides([action.index])
|
|
134
|
+
|
|
135
|
+
presentation.create_slide(action.index, requested_layout)
|
|
136
|
+
|
|
137
|
+
presentation.refresh
|
|
138
|
+
new_slide = current_slide_at(action.index)
|
|
139
|
+
requests = @request_builder.build_slide_update_requests(action.index, action.slide, new_slide)
|
|
140
|
+
|
|
141
|
+
presentation.batch_update(requests.compact)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def flush_pending_requests(state)
|
|
145
|
+
presentation.batch_update(state.all_requests)
|
|
146
|
+
state.clear_requests
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def current_slide_at(index) = presentation.instance_variable_get(:@presentation).slides[index]
|
|
150
|
+
|
|
151
|
+
class ExecutionState
|
|
152
|
+
attr_accessor :append_count
|
|
153
|
+
attr_reader :appending_requests, :update_requests, :delete_indices, :current_slide_count
|
|
154
|
+
|
|
155
|
+
def initialize(current_slide_count)
|
|
156
|
+
@current_slide_count = current_slide_count
|
|
157
|
+
@append_count = 0
|
|
158
|
+
@appending_requests = []
|
|
159
|
+
@update_requests = []
|
|
160
|
+
@delete_indices = []
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def all_requests = appending_requests + update_requests
|
|
164
|
+
|
|
165
|
+
def clear_requests
|
|
166
|
+
@appending_requests = []
|
|
167
|
+
@update_requests = []
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rdeck
|
|
4
|
+
class BlockQuote
|
|
5
|
+
attr_accessor :paragraphs, :nesting
|
|
6
|
+
|
|
7
|
+
def initialize(paragraphs: [], nesting: 0)
|
|
8
|
+
@paragraphs = paragraphs
|
|
9
|
+
@nesting = nesting
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def ==(other)
|
|
13
|
+
return false unless other.is_a?(BlockQuote)
|
|
14
|
+
|
|
15
|
+
paragraphs == other.paragraphs &&
|
|
16
|
+
nesting == other.nesting
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def text
|
|
20
|
+
paragraphs.map(&:text).join("\n")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def empty?
|
|
24
|
+
paragraphs.empty? || paragraphs.all?(&:empty?)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|