Repository navigation
Expand file tree
/
Copy pathdeck.go
More file actions
553 lines (502 loc) · 13.6 KB
/
Copy pathdeck.go
File metadata and controls
553 lines (502 loc) · 13.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
package deck
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"regexp"
"slices"
"strings"
"github.com/k1LoW/deck/config"
"github.com/k1LoW/errors"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
"google.golang.org/api/slides/v1"
)
const layoutNameForStyle = "style"
var profileRe = regexp.MustCompile(`^[a-zA-Z0-9_-]*$`)
type Deck struct {
id string
profile string
folderID string
srv *slides.Service
driveSrv *drive.Service
presentation *slides.Presentation
defaultTitleLayout string
defaultLayout string
styles map[string]*slides.TextStyle
shapes map[string]*slides.ShapeProperties
tableStyle *TableStyle
logger *slog.Logger
fresh bool
stylesFrozen bool
}
type Option func(*Deck) error
func WithPresentationID(id string) Option {
return func(d *Deck) error {
d.id = id
return nil
}
}
func WithLogger(logger *slog.Logger) Option {
return func(d *Deck) error {
d.logger = logger
return nil
}
}
func WithProfile(profile string) Option {
return func(d *Deck) error {
if !profileRe.MatchString(profile) {
return fmt.Errorf("invalid profile name: %s, only alphanumeric characters, underscores, and hyphens are allowed", profile)
}
d.profile = profile
return nil
}
}
func WithFolderID(folderID string) Option {
return func(d *Deck) error {
d.folderID = folderID
return nil
}
}
type placeholder struct {
objectID string
x float64
y float64
}
type bulletRange struct {
bullet Bullet
start int64
end int64
}
type textBox struct {
paragraphs []*Paragraph
fromMarkdown bool
}
// Presentation represents a Google Slides presentation.
type Presentation struct {
ID string
Title string
}
// New creates a new Deck.
func New(ctx context.Context, opts ...Option) (_ *Deck, err error) {
defer func() {
err = errors.WithStack(err)
}()
d, err := newDeck(ctx, opts...)
if err != nil {
return nil, err
}
if err := d.refresh(ctx); err != nil {
return nil, err
}
return d, nil
}
// Create Google Slides presentation.
func Create(ctx context.Context, opts ...Option) (_ *Deck, err error) {
defer func() {
err = errors.WithStack(err)
}()
d, err := newDeck(ctx, opts...)
if err != nil {
return nil, err
}
title := "Untitled"
file := &drive.File{
Name: title,
MimeType: "application/vnd.google-apps.presentation",
}
if d.folderID != "" {
file.Parents = []string{d.folderID}
}
f, err := d.driveSrv.Files.Create(file).SupportsAllDrives(true).Do()
if err != nil {
return nil, err
}
d.id = f.Id
if err := d.refresh(ctx); err != nil {
return nil, err
}
return d, nil
}
// CreateFrom creates a new Deck from the presentation ID.
func CreateFrom(ctx context.Context, id string, opts ...Option) (_ *Deck, err error) {
defer func() {
err = errors.WithStack(err)
}()
d, err := newDeck(ctx, opts...)
if err != nil {
return nil, err
}
// copy presentation
file := &drive.File{
Name: "Untitled",
MimeType: "application/vnd.google-apps.presentation",
}
if d.folderID != "" {
file.Parents = []string{d.folderID}
}
f, err := d.driveSrv.Files.Copy(id, file).SupportsAllDrives(true).Do()
if err != nil {
return nil, err
}
d.id = f.Id
if err := d.refresh(ctx); err != nil {
return nil, err
}
// delete all slides
if err := d.DeletePageAfter(ctx, -1); err != nil {
return nil, err
}
// create first slide
if err := d.createPage(ctx, 0, &Slide{
Layout: d.defaultTitleLayout,
}); err != nil {
return nil, err
}
return d, nil
}
func Doctor(ctx context.Context, opts ...Option) error {
d, err := newDeck(ctx, opts...)
if err != nil {
return err
}
_, err = d.getDefaultHTTPClient(ctx)
return err
}
// ID returns the ID of the presentation.
func (d *Deck) ID() string {
return d.id
}
// UpdateTitle updates the title of the presentation.
func (d *Deck) UpdateTitle(ctx context.Context, title string) (err error) {
defer func() {
err = errors.WithStack(err)
}()
file := &drive.File{
Name: title,
}
if _, err := d.driveSrv.Files.Update(d.id, file).SupportsAllDrives(true).Context(ctx).Do(); err != nil {
return err
}
return nil
}
// Export the presentation as PDF.
func (d *Deck) Export(ctx context.Context, w io.Writer) (err error) {
defer func() {
err = errors.WithStack(err)
}()
req, err := d.driveSrv.Files.Export(d.id, "application/pdf").Context(ctx).Download()
if err != nil {
return err
}
if err := req.Write(w); err != nil {
return fmt.Errorf("unable to create PDF file: %w", err)
}
return nil
}
func (d *Deck) DeletePages(ctx context.Context, indices []int) (err error) {
defer func() {
err = errors.WithStack(err)
}()
reqs := make([]*slides.Request, 0, len(indices))
for _, idx := range indices {
if len(d.presentation.Slides) <= idx {
continue
}
currentSlide := d.presentation.Slides[idx]
reqs = append(reqs, &slides.Request{
DeleteObject: &slides.DeleteObjectRequest{
ObjectId: currentSlide.ObjectId,
},
})
}
if len(reqs) > 0 {
d.logger.Info("deleting pages", slog.Any("indices", indices))
if err := d.batchUpdate(ctx, reqs); err != nil {
return fmt.Errorf("failed to delete pages: %w", err)
}
if err := d.refresh(ctx); err != nil {
return fmt.Errorf("failed to refresh presentation after delete pages: %w", err)
}
d.logger.Info("deleted pages", slog.Int("count", len(reqs)), slog.Any("indices", indices))
}
return nil
}
func (d *Deck) DeletePageAfter(ctx context.Context, index int) (err error) {
defer func() {
err = errors.WithStack(err)
}()
if len(d.presentation.Slides) <= index+1 {
return nil
}
var reqs []*slides.Request
for i := index + 1; i < len(d.presentation.Slides); i++ {
reqs = append(reqs, &slides.Request{
DeleteObject: &slides.DeleteObjectRequest{
ObjectId: d.presentation.Slides[i].ObjectId,
},
})
}
if err := d.batchUpdate(ctx, reqs); err != nil {
return err
}
if err := d.refresh(ctx); err != nil {
return err
}
return nil
}
func (d *Deck) MovePage(ctx context.Context, from_index, to_index int) (err error) {
defer func() {
err = errors.WithStack(err)
}()
d.logger.Info("moving page", slog.Int("from_index", from_index), slog.Int("to_index", to_index))
if err := d.movePage(ctx, from_index, to_index); err != nil {
return err
}
d.logger.Info("moved page", slog.Int("from_index", from_index), slog.Int("to_index", to_index))
return nil
}
// AllowReadingByAnyone sets the permission of the object to allow anyone to read it.
func (d *Deck) AllowReadingByAnyone(ctx context.Context, objectID string) (err error) {
defer func() {
err = errors.WithStack(err)
}()
permission := &drive.Permission{
Type: "anyone",
Role: "reader",
}
if _, err := d.driveSrv.Permissions.Create(objectID, permission).SupportsAllDrives(true).Context(ctx).Do(); err != nil {
return fmt.Errorf("failed to set permission: %w", err)
}
return nil
}
func newDeck(ctx context.Context, opts ...Option) (*Deck, error) {
d := &Deck{
styles: map[string]*slides.TextStyle{},
shapes: map[string]*slides.ShapeProperties{},
tableStyle: defaultTableStyle(),
}
for _, opt := range opts {
if err := opt(d); err != nil {
return nil, err
}
}
err := d.initialize(ctx)
return d, err
}
var HTTPClientError = errors.New("http client error")
func (d *Deck) initialize(ctx context.Context) (err error) {
defer func() {
err = errors.WithStack(err)
}()
if d.logger == nil {
d.logger = slog.New(slog.NewJSONHandler(io.Discard, nil))
}
if err := os.MkdirAll(config.StateHomePath(), 0700); err != nil {
return err
}
// Get client option (service account or OAuth2)
client, err := d.getHTTPClient(ctx)
if err != nil {
return errors.Join(err, HTTPClientError)
}
srv, err := slides.NewService(ctx, option.WithHTTPClient(client))
if err != nil {
return err
}
srv.UserAgent = userAgent
d.srv = srv
driveSrv, err := drive.NewService(ctx, option.WithHTTPClient(client))
if err != nil {
return err
}
driveSrv.UserAgent = userAgent
d.driveSrv = driveSrv
return nil
}
func (d *Deck) createPage(ctx context.Context, index int, slide *Slide) (err error) {
defer func() {
err = errors.WithStack(err)
}()
layoutMap := d.layoutMap()
layout, ok := layoutMap[slide.Layout]
if !ok {
return fmt.Errorf("layout not found: %q", slide.Layout)
}
// create new page
reqs := []*slides.Request{{
CreateSlide: &slides.CreateSlideRequest{
InsertionIndex: int64(index),
SlideLayoutReference: &slides.LayoutReference{
LayoutId: layout.ObjectId,
},
},
}}
if err := d.batchUpdate(ctx, reqs); err != nil {
return fmt.Errorf("failed to create page: %w", err)
}
if err := d.refresh(ctx); err != nil {
return err
}
return nil
}
// preparePages prepares the pages by creating slides with the specified layout IDs.
func (d *Deck) preparePages(ctx context.Context, startIdx int, layoutIDs []string) (err error) {
defer func() {
err = errors.WithStack(err)
}()
slideIdx := startIdx
reqs := make([]*slides.Request, len(layoutIDs))
for i, layoutID := range layoutIDs {
reqs[i] = &slides.Request{
CreateSlide: &slides.CreateSlideRequest{
InsertionIndex: int64(slideIdx),
SlideLayoutReference: &slides.LayoutReference{
LayoutId: layoutID,
},
},
}
slideIdx++
}
if err := d.batchUpdate(ctx, reqs); err != nil {
return err
}
d.logger.Debug("prepared pages", slog.Int("count", len(layoutIDs)), slog.Int("start_index", startIdx))
return d.refresh(ctx)
}
func (d *Deck) movePage(ctx context.Context, from_index, to_index int) (err error) {
defer func() {
err = errors.WithStack(err)
}()
if from_index == to_index || from_index < 0 || to_index < 0 || from_index >= len(d.presentation.Slides) || to_index >= len(d.presentation.Slides) {
return nil
}
currentSlide := d.presentation.Slides[from_index]
if from_index < to_index {
to_index++
}
reqs := []*slides.Request{{
UpdateSlidesPosition: &slides.UpdateSlidesPositionRequest{
SlideObjectIds: []string{currentSlide.ObjectId},
InsertionIndex: int64(to_index),
ForceSendFields: []string{"InsertionIndex"},
},
}}
if err := d.batchUpdate(ctx, reqs); err != nil {
return err
}
if err := d.refresh(ctx); err != nil {
return err
}
return nil
}
func (d *Deck) layoutMap() map[string]*slides.Page {
layoutMap := map[string]*slides.Page{}
for _, l := range d.presentation.Layouts {
layoutMap[l.LayoutProperties.DisplayName] = l
}
return layoutMap
}
// validateLayouts validates that all layouts used in slides exist in the presentation.
// It returns an error if any layout is not found, with available layouts listed in the error message.
func (d *Deck) validateLayouts(ss Slides) (err error) {
defer func() {
err = errors.WithStack(err)
}()
layoutMap := d.layoutMap()
var notFound []string
for i, slide := range ss {
layout := slide.Layout
if layout == "" {
// Use default layout if not specified
if i == 0 {
layout = d.defaultTitleLayout
} else {
layout = d.defaultLayout
}
}
if _, ok := layoutMap[layout]; !ok {
notFound = append(notFound, layout)
}
}
if len(notFound) > 0 {
slices.Sort(notFound)
notFound = slices.Compact(notFound)
var available []string
for name := range layoutMap {
available = append(available, name)
}
slices.Sort(available)
return fmt.Errorf("layout not found: %q\navailable layouts: %v", notFound, available)
}
return nil
}
func (d *Deck) refresh(ctx context.Context) (err error) {
defer func() {
err = errors.WithStack(err)
}()
if d.fresh {
return nil
}
presentation, err := d.srv.Presentations.Get(d.id).Context(ctx).Do()
if err != nil {
return err
}
d.presentation = presentation
// set default layouts
for _, l := range d.presentation.Layouts {
layout := l.LayoutProperties.Name
switch {
case strings.HasPrefix(layout, "TITLE_AND_BODY"):
if d.defaultLayout == "" {
d.defaultLayout = l.LayoutProperties.DisplayName
}
case strings.HasPrefix(layout, "TITLE"):
if d.defaultTitleLayout == "" {
d.defaultTitleLayout = l.LayoutProperties.DisplayName
}
}
}
if !d.stylesFrozen {
d.loadStyles()
}
// If the default layouts that were derived are renamed or otherwise disappear, search for them again.
// The defaultLayout may be an empty string, but even in that case, the layout search from the map
// will fail, so this case is also covered.
layoutMap := d.layoutMap()
_, defaultTitleLayoutFound := layoutMap[d.defaultTitleLayout]
_, defaultLayoutFound := layoutMap[d.defaultLayout]
if !defaultTitleLayoutFound {
d.defaultTitleLayout = d.presentation.Layouts[0].LayoutProperties.DisplayName
}
if !defaultLayoutFound {
if len(d.presentation.Layouts) > 1 {
d.defaultLayout = d.presentation.Layouts[1].LayoutProperties.DisplayName
} else {
d.defaultLayout = d.presentation.Layouts[0].LayoutProperties.DisplayName
}
}
d.fresh = true
return nil
}
// deleteOrTrashFile attempts to delete a file, or move it to trash if deletion is not allowed.
func (d *Deck) deleteOrTrashFile(ctx context.Context, id string) error {
file, err := d.driveSrv.Files.Get(id).SupportsAllDrives(true).Fields("capabilities").Context(ctx).Do()
if err != nil {
return fmt.Errorf("file not found or not accessible before deletion (file ID: %s): %w", id, err)
}
if file.Capabilities == nil || file.Capabilities.CanDelete {
return d.driveSrv.Files.Delete(id).SupportsAllDrives(true).Context(ctx).Do()
}
if file.Capabilities.CanTrash {
updateRequest := &drive.File{Trashed: true}
_, err := d.driveSrv.Files.Update(id, updateRequest).SupportsAllDrives(true).Context(ctx).Do()
if err != nil {
return fmt.Errorf("failed to trash presentation: %w", err)
}
return nil
}
return fmt.Errorf("file cannot be deleted or trashed (file ID: %s)", id)
}