diff --git a/cmd/apply.go b/cmd/apply.go index a753afc4..e74bed09 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -53,6 +53,8 @@ var ( logger *slog.Logger codeBlockToImageCmd string applyFolderID string + imageUploadCmd string + imageDeleteCmd string tb = tail.New(30) ) @@ -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) { @@ -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)") } diff --git a/deck.go b/deck.go index e28e0184..fcf92dbe 100644 --- a/deck.go +++ b/deck.go @@ -35,6 +35,8 @@ type Deck struct { tableStyle *TableStyle logger *slog.Logger fresh bool + imageUploadCmd string + imageDeleteCmd string } type Option func(*Deck) error @@ -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 @@ -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) +} diff --git a/md/cel.go b/md/cel.go index 26662654..dee3087c 100644 --- a/md/cel.go +++ b/md/cel.go @@ -2,7 +2,6 @@ package md import ( "fmt" - "regexp" "strings" "github.com/google/cel-go/cel" @@ -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 - } -} diff --git a/md/md.go b/md/md.go index 8defb8df..3484690c 100644 --- a/md/md.go +++ b/md/md.go @@ -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" @@ -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. @@ -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 } @@ -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 -} diff --git a/preload.go b/preload.go index 626fedae..598d9915 100644 --- a/preload.go +++ b/preload.go @@ -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" ) @@ -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 } @@ -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 @@ -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 }) } @@ -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: @@ -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)) diff --git a/storage.go b/storage.go new file mode 100644 index 00000000..8468818a --- /dev/null +++ b/storage.go @@ -0,0 +1,244 @@ +package deck + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/k1LoW/deck/template" + "github.com/k1LoW/errors" + "github.com/k1LoW/exec" + "google.golang.org/api/drive/v3" +) + +// uploadResult represents the JSON output from the external upload command. +type uploadResult struct { + URL string `json:"url"` + ID string `json:"id"` +} + +// Storage is the interface for image upload/delete operations. +type Storage interface { + Upload(ctx context.Context, data []byte, mimeType string) (publicURL, uploadedID string, err error) + Delete(ctx context.Context, uploadedID string) error +} + +// googleDriveStorage implements Storage using Google Drive. +type googleDriveStorage struct { + driveSrv *drive.Service + folderID string + allowReadingByAnyone func(ctx context.Context, fileID string) error + deleteOrTrash func(ctx context.Context, fileID string) error +} + +// newGoogleDriveStorage creates a new googleDriveStorage. +func newGoogleDriveStorage( + driveSrv *drive.Service, + folderID string, + allowReadingByAnyone func(ctx context.Context, fileID string) error, + deleteOrTrash func(ctx context.Context, fileID string) error, +) *googleDriveStorage { + return &googleDriveStorage{ + driveSrv: driveSrv, + folderID: folderID, + allowReadingByAnyone: allowReadingByAnyone, + deleteOrTrash: deleteOrTrash, + } +} + +// Upload uploads an image to Google Drive. +func (u *googleDriveStorage) Upload(ctx context.Context, data []byte, mimeType string) (publicURL, uploadedID string, err error) { + df := &drive.File{ + Name: fmt.Sprintf("________tmp-for-deck-%s", time.Now().Format(time.RFC3339)), + MimeType: mimeType, + } + if u.folderID != "" { + df.Parents = []string{u.folderID} + } + + uploaded, err := u.driveSrv.Files.Create(df).Media(bytes.NewBuffer(data)).SupportsAllDrives(true).Do() + if err != nil { + return "", "", fmt.Errorf("failed to upload image: %w", err) + } + uploadedID = uploaded.Id + + defer func() { + if err != nil { + // Clean up uploaded file on error + if deleteErr := u.deleteOrTrash(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 = u.allowReadingByAnyone(ctx, uploaded.Id); err != nil { + return "", "", fmt.Errorf("failed to set permission for image: %w", err) + } + + // Get webContentLink + f, err := u.driveSrv.Files.Get(uploaded.Id).Fields("webContentLink").SupportsAllDrives(true).Do() + if err != nil { + return "", "", fmt.Errorf("failed to get webContentLink for image: %w", err) + } + + if f.WebContentLink == "" { + return "", "", fmt.Errorf("webContentLink is empty for image: %s", uploaded.Id) + } + publicURL = f.WebContentLink + + return publicURL, uploadedID, nil +} + +// Delete deletes an uploaded image from Google Drive. +func (u *googleDriveStorage) Delete(ctx context.Context, uploadedID string) error { + return u.deleteOrTrash(ctx, uploadedID) +} + +// externalStorage implements Storage using external CLI commands. +type externalStorage struct { + uploadCmd string + deleteCmd string +} + +// newExternalStorage creates a new externalStorage. +func newExternalStorage(uploadCmd, deleteCmd string) *externalStorage { + return &externalStorage{ + uploadCmd: uploadCmd, + deleteCmd: deleteCmd, + } +} + +// Upload uploads an image using the external upload command. +// It passes image data via stdin and sets the environment variable DECK_UPLOAD_MIME. +// The command also supports template variables: {{mime}} and {{env.XXX}}. +// The command should output JSON: {"url":"...","id":"..."} +func (u *externalStorage) Upload(ctx context.Context, data []byte, mimeType string) (publicURL, uploadedID string, err error) { + const envUploadMIME = "DECK_UPLOAD_MIME" + + // Prepare environment variables + env := template.EnvironToMap() + env[envUploadMIME] = mimeType + + // Prepare template store + store := map[string]any{ + "mime": mimeType, + "env": env, + } + + // Expand template in command + expandedCmd, err := template.Expand(u.uploadCmd, store) + if err != nil { + return "", "", fmt.Errorf("failed to expand upload command template: %w", err) + } + + c, args, err := buildCommand(expandedCmd) + if err != nil { + return "", "", fmt.Errorf("failed to build upload command: %w", err) + } + + cmd := exec.CommandContext(ctx, c, args...) + cmd.Stdin = bytes.NewReader(data) + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, envUploadMIME+"="+mimeType) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", "", fmt.Errorf("failed to run upload command: %w\nstderr: %s", err, stderr.String()) + } + + // Parse JSON output + var result uploadResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + return "", "", fmt.Errorf("failed to parse upload command output as JSON: %w\nstdout: %s", err, stdout.String()) + } + + if result.URL == "" { + return "", "", fmt.Errorf("upload command returned empty URL") + } + if result.ID == "" { + return "", "", fmt.Errorf("upload command returned empty ID") + } + + return result.URL, result.ID, nil +} + +// Delete deletes an uploaded image using the external delete command. +// It sets the environment variable DECK_DELETE_ID with the uploaded ID. +// The command also supports template variables: {{id}} and {{env.XXX}}. +func (u *externalStorage) Delete(ctx context.Context, uploadedID string) error { + const envDeleteID = "DECK_DELETE_ID" + + if u.deleteCmd == "" { + // No delete command configured, skip deletion + return nil + } + + // Prepare environment variables + env := template.EnvironToMap() + env[envDeleteID] = uploadedID + + // Prepare template store + store := map[string]any{ + "id": uploadedID, + "env": env, + } + + // Expand template in command + expandedCmd, err := template.Expand(u.deleteCmd, store) + if err != nil { + return fmt.Errorf("failed to expand delete command template: %w", err) + } + + c, args, err := buildCommand(expandedCmd) + if err != nil { + return fmt.Errorf("failed to build delete command: %w", err) + } + + cmd := exec.CommandContext(ctx, c, args...) + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, envDeleteID+"="+uploadedID) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to run delete command: %w\nstderr: %s", err, stderr.String()) + } + + return nil +} + +// buildCommand parses a command string and returns the command and arguments. +func buildCommand(cmdStr string) (string, []string, error) { + shell, err := detectShell() + if err != nil { + return "", nil, err + } + return shell, []string{"-c", cmdStr}, nil +} + +// detectShell detects the current shell. +func detectShell() (string, error) { + shells := []string{ + os.Getenv("SHELL"), + "/bin/bash", + "/bin/sh", + } + for _, shell := range shells { + if shell == "" { + continue + } + if _, err := os.Stat(shell); err == nil { + return shell, nil + } + } + return "", fmt.Errorf("failed to detect shell") +} diff --git a/template/template.go b/template/template.go new file mode 100644 index 00000000..8b87d30f --- /dev/null +++ b/template/template.go @@ -0,0 +1,106 @@ +package template + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/google/cel-go/cel" +) + +// Regular expression to match {{expression}} patterns. +var celExprReg = regexp.MustCompile(`\{\{([^}]+)\}\}`) + +// Expand expands template expressions in the format {{CEL expression}} with values from the store. +// It supports CEL (Common Expression Language) expressions within the template. +func Expand(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 + } +} + +// EnvironToMap converts environment variables to a map. +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 +} diff --git a/md/template_test.go b/template/template_test.go similarity index 94% rename from md/template_test.go rename to template/template_test.go index 0022c7e6..cca1e802 100644 --- a/md/template_test.go +++ b/template/template_test.go @@ -1,10 +1,10 @@ -package md +package template import ( "testing" ) -func TestExpandTemplate(t *testing.T) { +func TestExpand(t *testing.T) { tests := []struct { name string template string @@ -187,22 +187,22 @@ func TestExpandTemplate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := expandTemplate(tt.template, tt.store) + result, err := Expand(tt.template, tt.store) if tt.wantErr { if err == nil { - t.Errorf("expandTemplate() expected error but got none") + t.Errorf("Expand() expected error but got none") } return } if err != nil { - t.Errorf("expandTemplate() unexpected error: %v", err) + t.Errorf("Expand() unexpected error: %v", err) return } if result != tt.expected { - t.Errorf("expandTemplate() = %q, want %q", result, tt.expected) + t.Errorf("Expand() = %q, want %q", result, tt.expected) } }) }