gogcli/internal/cmd/docs_import.go
Chris Parsons 8b08d11777
feat(slides): add add-slide, list-slides, and delete-slide commands (#214)
* feat(slides): add `slides add-slide` command for full-bleed image slides

Adds a new `gog slides add-slide <presentationId> <image>` command that
appends a slide with a full-bleed image and optional speaker notes using
the native Google Slides API (presentations.batchUpdate).

Workflow: create a deck with `gog slides create`, then add slides one at
a time with this command. Supports --notes for inline text and
--notes-file for multiline markdown speaker notes.

Also registers ServiceSlides in the auth layer with proper scopes and
readonly support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(slides): add list-slides and delete-slide commands

list-slides shows all slide object IDs in a presentation.
delete-slide removes a slide by its object ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(slides): add --before flag to add-slide for insertion ordering

Allow inserting a slide before a specific existing slide ID instead of
always appending to the end of the presentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(slides): add read-slide, update-notes, and replace-slide commands

Adds three new commands to support in-place editing of existing slides:

- `slides read-slide` — shows slide content including speaker notes,
  text elements, and image references (supports --json output)
- `slides update-notes` — updates speaker notes on an existing slide
  without deleting/re-adding (--notes or --notes-file)
- `slides replace-slide` — atomically swaps the image on an existing
  slide using the ReplaceImage API, optionally updating notes in the
  same batch operation

These eliminate the error-prone delete+add-before workflow when editing
slides in existing decks.

Closes chrismdp/gogcli#1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(slides): rebase PR 214, clear notes semantics, and hard-fail missing placeholders (#214) (thanks @chrismdp)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 00:21:59 +01:00

283 lines
7.7 KiB
Go

package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"google.golang.org/api/docs/v1"
"google.golang.org/api/drive/v3"
)
// markdownImage holds a parsed image reference from a markdown file.
type markdownImage struct {
index int // sequential index (0, 1, 2, ...)
alt string // alt text
originalRef string // original path or URL
}
// placeholder returns the placeholder string for this image.
func (m markdownImage) placeholder() string {
return fmt.Sprintf("<<IMG_%d>>", m.index)
}
// isRemote returns true if the image reference is a remote URL.
func (m markdownImage) isRemote() bool {
return strings.HasPrefix(m.originalRef, "http://") || strings.HasPrefix(m.originalRef, "https://")
}
var mdImageRe = regexp.MustCompile(`!\[([^\]]*)\]\((?:<([^>]+)>|([^)\s]+))(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\)`)
// extractMarkdownImages finds all ![alt](url) references in content,
// replaces them with <<IMG_N>> placeholders, and returns the cleaned
// content along with the extracted images.
func extractMarkdownImages(content string) (string, []markdownImage) {
var images []markdownImage
idx := 0
cleaned := mdImageRe.ReplaceAllStringFunc(content, func(match string) string {
subs := mdImageRe.FindStringSubmatch(match)
if len(subs) < 4 {
return match
}
ref := subs[2]
if ref == "" {
ref = subs[3]
}
img := markdownImage{
index: idx,
alt: subs[1],
originalRef: ref,
}
images = append(images, img)
placeholder := img.placeholder()
idx++
return placeholder
})
return cleaned, images
}
// docRange represents a start/end character index range in a Google Doc.
type docRange struct {
startIndex int64
endIndex int64
}
// findPlaceholderIndices walks a Google Doc body to locate <<IMG_N>> placeholders
// and returns a map from placeholder string to its position.
func findPlaceholderIndices(doc *docs.Document, count int) map[string]docRange {
result := make(map[string]docRange)
if doc == nil || doc.Body == nil || count == 0 {
return result
}
// Build the set of placeholders we're looking for.
placeholders := make([]string, count)
for i := 0; i < count; i++ {
placeholders[i] = fmt.Sprintf("<<IMG_%d>>", i)
}
for _, el := range doc.Body.Content {
if el.Paragraph == nil {
continue
}
for _, pe := range el.Paragraph.Elements {
if pe.TextRun == nil {
continue
}
text := pe.TextRun.Content
for _, ph := range placeholders {
pos := strings.Index(text, ph)
if pos == -1 {
continue
}
absStart := pe.StartIndex + int64(pos)
absEnd := absStart + int64(len(ph))
result[ph] = docRange{
startIndex: absStart,
endIndex: absEnd,
}
}
}
}
return result
}
// uploadLocalImage uploads a local image to Google Drive with public read access,
// returning the public URL and the Drive file ID (for cleanup).
func uploadLocalImage(ctx context.Context, driveSvc *drive.Service, path string) (url string, fileID string, err error) {
ext := strings.ToLower(filepath.Ext(path))
var mimeType string
switch ext {
case extPNG:
mimeType = mimePNG
case imageExtJPG, imageExtJPEG:
mimeType = imageMimeJPEG
case imageExtGIF:
mimeType = imageMimeGIF
default:
return "", "", fmt.Errorf("unsupported image format %q (use PNG, JPG, or GIF)", ext)
}
// #nosec G304 -- path is validated by resolveMarkdownImagePath before upload.
f, err := os.Open(path)
if err != nil {
return "", "", fmt.Errorf("open image %q: %w", path, err)
}
defer f.Close()
driveFile, err := driveSvc.Files.Create(&drive.File{
Name: filepath.Base(path),
MimeType: mimeType,
}).Media(f).Fields("id, webContentLink").Context(ctx).Do()
if err != nil {
return "", "", fmt.Errorf("upload image to Drive: %w", err)
}
// Make publicly readable so the Docs API can fetch it.
_, err = driveSvc.Permissions.Create(driveFile.Id, &drive.Permission{
Type: "anyone",
Role: "reader",
}).Context(ctx).Do()
if err != nil {
deleteDriveFileBestEffort(ctx, driveSvc, driveFile.Id)
return "", "", fmt.Errorf("set image permissions: %w", err)
}
imageURL := driveFile.WebContentLink
if imageURL == "" {
got, err := driveSvc.Files.Get(driveFile.Id).Fields("webContentLink").Context(ctx).Do()
if err != nil {
deleteDriveFileBestEffort(ctx, driveSvc, driveFile.Id)
return "", "", fmt.Errorf("get image URL: %w", err)
}
imageURL = got.WebContentLink
}
if imageURL == "" {
deleteDriveFileBestEffort(ctx, driveSvc, driveFile.Id)
return "", "", fmt.Errorf("could not obtain public URL for uploaded image %q", path)
}
return imageURL, driveFile.Id, nil
}
func cleanupDriveFileIDsBestEffort(ctx context.Context, driveSvc *drive.Service, fileIDs []string) {
if len(fileIDs) == 0 {
return
}
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
for _, id := range fileIDs {
if strings.TrimSpace(id) == "" {
continue
}
_ = driveSvc.Files.Delete(id).Context(cleanupCtx).Do()
}
}
func deleteDriveFileBestEffort(ctx context.Context, driveSvc *drive.Service, fileID string) {
if strings.TrimSpace(fileID) == "" {
return
}
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
_ = driveSvc.Files.Delete(fileID).Context(cleanupCtx).Do()
}
func resolveMarkdownImagePath(markdownFilePath string, imageRef string) (string, error) {
mdDir, err := filepath.Abs(filepath.Dir(markdownFilePath))
if err != nil {
return "", fmt.Errorf("resolve markdown directory: %w", err)
}
realDir, err := filepath.EvalSymlinks(mdDir)
if err != nil {
return "", fmt.Errorf("resolve markdown directory: %w", err)
}
imgPath := imageRef
if !filepath.IsAbs(imgPath) {
imgPath = filepath.Join(mdDir, imgPath)
}
imgPath = filepath.Clean(imgPath)
realPath, err := filepath.EvalSymlinks(imgPath)
if err != nil {
return "", fmt.Errorf("resolve image path %q: %w", imageRef, err)
}
if !pathWithinDir(realPath, realDir) {
return "", fmt.Errorf("image path %q resolves outside markdown file directory", imageRef)
}
return realPath, nil
}
func pathWithinDir(path string, dir string) bool {
rel, err := filepath.Rel(dir, path)
if err != nil {
return false
}
if rel == ".." {
return false
}
return !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
// buildImageInsertRequests creates the Docs API batch update requests to replace
// placeholder text with inline images. Requests are ordered in reverse index order
// so earlier positions are not invalidated as the document is modified.
func buildImageInsertRequests(placeholders map[string]docRange, images []markdownImage, imageURLs map[int]string) []*docs.Request {
// Collect entries sorted by start index descending.
type entry struct {
image markdownImage
dr docRange
url string
}
var entries []entry
for _, img := range images {
ph := img.placeholder()
dr, ok := placeholders[ph]
if !ok {
continue
}
u, ok := imageURLs[img.index]
if !ok {
continue
}
entries = append(entries, entry{image: img, dr: dr, url: u})
}
// Sort by start index descending; process from end of document to start.
sort.Slice(entries, func(i, j int) bool {
return entries[i].dr.startIndex > entries[j].dr.startIndex
})
reqs := make([]*docs.Request, 0, len(entries)*2)
for _, e := range entries {
// First delete the placeholder text.
reqs = append(reqs, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{
StartIndex: e.dr.startIndex,
EndIndex: e.dr.endIndex,
},
},
})
// Then insert the image at that position.
reqs = append(reqs, &docs.Request{
InsertInlineImage: &docs.InsertInlineImageRequest{
Uri: e.url,
Location: &docs.Location{
Index: e.dr.startIndex,
},
},
})
}
return reqs
}