Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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