Skip to content
Merged
10 changes: 10 additions & 0 deletions cmd/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ var (
logger *slog.Logger
codeBlockToImageCmd string
applyFolderID string
imageUploadCmd string
imageDeleteCmd string
tb = tail.New(30)
)

Expand Down Expand Up @@ -154,6 +156,12 @@ var applyCmd = &cobra.Command{
if targetFolderID != "" {
opts = append(opts, deck.WithFolderID(targetFolderID))
}
if imageUploadCmd != "" {
opts = append(opts, deck.WithImageUploadCmd(imageUploadCmd))
}
if imageDeleteCmd != "" {
opts = append(opts, deck.WithImageDeleteCmd(imageDeleteCmd))
}
d, err := deck.New(ctx, opts...)
if err != nil {
if errors.Is(err, deck.HTTPClientError) {
Expand Down Expand Up @@ -202,6 +210,8 @@ func init() {
applyCmd.Flags().StringVarP(&page, "page", "p", "", "page to apply")
applyCmd.Flags().StringVarP(&codeBlockToImageCmd, "code-block-to-image-command", "c", "", "command to convert code blocks to images")
applyCmd.Flags().StringVarP(&applyFolderID, "folder-id", "", "", "folder id to upload temporary images to")
applyCmd.Flags().StringVarP(&imageUploadCmd, "image-upload-command", "u", "", "command to upload images (e.g., 'my-uploader upload')")
applyCmd.Flags().StringVarP(&imageDeleteCmd, "image-delete-command", "d", "", "command to delete uploaded images (e.g., 'my-uploader delete')")
applyCmd.Flags().BoolVarP(&watch, "watch", "w", false, "watch for changes")
applyCmd.Flags().CountVarP(&verbosity, "verbose", "v", "verbose output (can be used multiple times for more verbosity)")
}
Expand Down
29 changes: 29 additions & 0 deletions deck.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type Deck struct {
tableStyle *TableStyle
logger *slog.Logger
fresh bool
imageUploadCmd string
imageDeleteCmd string
}

type Option func(*Deck) error
Expand Down Expand Up @@ -70,6 +72,25 @@ func WithFolderID(folderID string) Option {
}
}

// WithImageUploadCmd sets the command to upload images to external storage.
// The command receives image data via stdin and the environment variable DECK_UPLOAD_MIME.
// It should output the public URL on the first line and uploaded ID on the second line of stdout.
func WithImageUploadCmd(cmd string) Option {
return func(d *Deck) error {
d.imageUploadCmd = cmd
return nil
}
}

// WithImageDeleteCmd sets the command to delete uploaded images from external storage.
// The command receives the uploaded ID via environment variable DECK_DELETE_ID.
func WithImageDeleteCmd(cmd string) Option {
return func(d *Deck) error {
d.imageDeleteCmd = cmd
return nil
}
}

type placeholder struct {
objectID string
x float64
Expand Down Expand Up @@ -572,3 +593,11 @@ func (d *Deck) deleteOrTrashFile(ctx context.Context, id string) error {
}
return fmt.Errorf("file cannot be deleted or trashed (file ID: %s)", id)
}

// getStorage returns the appropriate Storage based on configuration.
func (d *Deck) getStorage() Storage {
if d.imageUploadCmd != "" {
return newExternalStorage(d.imageUploadCmd, d.imageDeleteCmd)
}
return newGoogleDriveStorage(d.driveSrv, d.folderID, d.AllowReadingByAnyone, d.deleteOrTrashFile)
}
84 changes: 0 additions & 84 deletions md/cel.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package md

import (
"fmt"
"regexp"
"strings"

"github.com/google/cel-go/cel"
Expand Down Expand Up @@ -97,86 +96,3 @@ func (md *MD) reflectDefaults() error {
return nil
}

// Regular expression to match {{expression}} patterns.
var celExprReg = regexp.MustCompile(`\{\{([^}]+)\}\}`)

// expandTemplate expands template expressions in the format {{CEL expression}} with values from the store.
// It supports CEL (Common Expression Language) expressions within the template.
func expandTemplate(template string, store map[string]any) (string, error) {
// Create CEL environment with store variables
env, err := createCELEnv(store)
if err != nil {
return "", fmt.Errorf("failed to create CEL environment: %w", err)
}

var expandErr error
result := celExprReg.ReplaceAllStringFunc(template, func(match string) string {
// Extract CEL expression without {{ }}
expr := strings.TrimSpace(match[2 : len(match)-2])

// Compile and evaluate CEL expression
ast, issues := env.Compile(expr)
if issues != nil && issues.Err() != nil {
expandErr = fmt.Errorf("template compilation error for '{{%s}}': %w", expr, issues.Err())
return match // Return original match on error
}

prg, err := env.Program(ast)
if err != nil {
expandErr = fmt.Errorf("template program creation error for '{{%s}}': %w", expr, err)
return match // Return original match on error
}

out, _, err := prg.Eval(store)
if err != nil {
expandErr = fmt.Errorf("template evaluation error for '{{%s}}': %w", expr, err)
return match // Return original match on error
}

// Convert result to string
return fmt.Sprintf("%v", out.Value())
})

if expandErr != nil {
return "", expandErr
}

return result, nil
}

// createCELEnv creates a CEL environment with all variables from the store.
func createCELEnv(store map[string]any) (*cel.Env, error) {
var options []cel.EnvOption

// Add each top-level store key as a CEL variable
for key, value := range store {
celType := inferCELType(value)
options = append(options, cel.Variable(key, celType))
}

return cel.NewEnv(options...)
}

// inferCELType infers the CEL type from a Go value.
func inferCELType(value any) *cel.Type {
switch value.(type) {
case string:
return cel.StringType
case int, int32, int64:
return cel.IntType
case float32, float64:
return cel.DoubleType
case bool:
return cel.BoolType
case map[string]any:
return cel.MapType(cel.StringType, cel.AnyType)
case map[string]string:
return cel.MapType(cel.StringType, cel.StringType)
case []any:
return cel.ListType(cel.AnyType)
case []string:
return cel.ListType(cel.StringType)
default:
return cel.AnyType
}
}
15 changes: 3 additions & 12 deletions md/md.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/goccy/go-yaml"
"github.com/k1LoW/deck"
"github.com/k1LoW/deck/config"
"github.com/k1LoW/deck/template"
"github.com/k1LoW/errors"
"github.com/k1LoW/exec"
"github.com/yuin/goldmark"
Expand Down Expand Up @@ -564,7 +565,7 @@ func genCodeImage(ctx context.Context, codeBlockToImageCmd string, codeBlock *Co
defer os.RemoveAll(dir)

output := filepath.Join(dir, "out.png")
env := environToMap()
env := template.EnvironToMap()
env["CODEBLOCK_LANG"] = codeBlock.Language
env["CODEBLOCK_CONTENT"] = codeBlock.Content
env["CODEBLOCK_VALUE"] = codeBlock.Content // Deprecated, use CODEBLOCK_CONTENT.
Expand All @@ -577,7 +578,7 @@ func genCodeImage(ctx context.Context, codeBlockToImageCmd string, codeBlock *Co
"output": output,
"env": env,
}
replacedCmd, err := expandTemplate(codeBlockToImageCmd, store)
replacedCmd, err := template.Expand(codeBlockToImageCmd, store)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1050,13 +1051,3 @@ func splitPages(b []byte) [][]byte {
return bpages
}

func environToMap() map[string]string {
envMap := make(map[string]string)
for _, e := range os.Environ() {
parts := strings.SplitN(e, "=", 2)
if len(parts) == 2 {
envMap[parts[0]] = parts[1]
}
}
return envMap
}
58 changes: 13 additions & 45 deletions preload.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
package deck

import (
"bytes"
"context"
"fmt"
"log/slog"
"slices"
"sync"
"time"

"github.com/k1LoW/errors"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
"google.golang.org/api/drive/v3"
"google.golang.org/api/slides/v1"
)

Expand Down Expand Up @@ -155,7 +151,7 @@ func (d *Deck) preloadCurrentImages(ctx context.Context, actions []*action) (map

// uploadedImageInfo holds information about uploaded images for cleanup.
type uploadedImageInfo struct {
uploadedID string
uploadedID string // Google Drive file ID or external storage uploaded ID
image *Image
}

Expand Down Expand Up @@ -200,6 +196,9 @@ func (d *Deck) startUploadingImages(
image.StartUpload()
}

// Get storage instance
storage := d.getStorage()

// Start uploading images asynchronously
go func() {
// Process images in parallel
Expand All @@ -215,51 +214,17 @@ func (d *Deck) startUploadingImages(
}
defer sem.Release(1)

// Upload image to Google Drive
df := &drive.File{
Name: fmt.Sprintf("________tmp-for-deck-%s", time.Now().Format(time.RFC3339)),
MimeType: string(image.mimeType),
}
if d.folderID != "" {
df.Parents = []string{d.folderID}
}
uploaded, err := d.driveSrv.Files.Create(df).Media(bytes.NewBuffer(image.Bytes())).SupportsAllDrives(true).Do()
mimeType := string(image.mimeType)
publicURL, uploadedID, err := storage.Upload(ctx, image.Bytes(), mimeType)
if err != nil {
image.SetUploadResult("", fmt.Errorf("failed to upload image: %w", err))
return err
}
defer func() {
if err != nil {
// Clean up uploaded file on error
if deleteErr := d.deleteOrTrashFile(ctx, uploaded.Id); deleteErr != nil {
err = errors.Join(err, deleteErr)
}
}
}()

// To specify a URL for CreateImageRequest, we must make the webContentURL readable to anyone
// and configure the necessary permissions for this purpose.
if err := d.AllowReadingByAnyone(ctx, uploaded.Id); err != nil {
image.SetUploadResult("", fmt.Errorf("failed to set permission for image: %w", err))
return err
}

// Get webContentLink
f, err := d.driveSrv.Files.Get(uploaded.Id).Fields("webContentLink").SupportsAllDrives(true).Do()
if err != nil {
image.SetUploadResult("", fmt.Errorf("failed to get webContentLink for image: %w", err))
return err
}

if f.WebContentLink == "" {
image.SetUploadResult("", fmt.Errorf("webContentLink is empty for image: %s", uploaded.Id))
return err
}

// Set successful upload result
image.SetUploadResult(f.WebContentLink, nil)
image.SetUploadResult(publicURL, nil)

uploadedCh <- uploadedImageInfo{uploadedID: uploaded.Id, image: image}
uploadedCh <- uploadedImageInfo{uploadedID: uploadedID, image: image}
return nil
})
}
Expand All @@ -280,6 +245,9 @@ func (d *Deck) cleanupUploadedImages(ctx context.Context, uploadedCh <-chan uplo
sem := semaphore.NewWeighted(maxPreloadWorkersNum)
var wg sync.WaitGroup

// Get storage instance
storage := d.getStorage()

for {
select {
case info, ok := <-uploadedCh:
Expand All @@ -300,11 +268,11 @@ func (d *Deck) cleanupUploadedImages(ctx context.Context, uploadedCh <-chan uplo
wg.Done()
}()

// Delete uploaded image from Google Drive
// Delete uploaded image
// Note: We only log errors here instead of returning them to ensure
// all images are attempted to be deleted. A single deletion failure
// should not prevent cleanup of other successfully uploaded images.
if err := d.deleteOrTrashFile(ctx, info.uploadedID); err != nil {
if err := storage.Delete(ctx, info.uploadedID); err != nil {
d.logger.Error("failed to delete uploaded image",
slog.String("id", info.uploadedID),
slog.Any("error", err))
Expand Down
Loading