mvp implementation
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
module github.com/end/officeconvert/clients/go
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
connectrpc.com/connect v1.19.1
|
||||
github.com/end/officeconvert v0.0.0
|
||||
)
|
||||
|
||||
require google.golang.org/protobuf v1.36.11 // indirect
|
||||
|
||||
replace github.com/end/officeconvert => ../..
|
||||
@@ -0,0 +1,6 @@
|
||||
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
|
||||
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
@@ -0,0 +1,147 @@
|
||||
// Package officeconvertclient provides typed Connect RPC helpers for conversions.
|
||||
package officeconvertclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
officeconvertapiv1 "github.com/end/officeconvert/gen/go/officeconvertapi/v1"
|
||||
"github.com/end/officeconvert/gen/go/officeconvertapi/v1/officeconvertapiv1connect"
|
||||
)
|
||||
|
||||
const defaultPollInterval = 2 * time.Second
|
||||
|
||||
// Client wraps the generated Connect client with orchestration-focused helpers.
|
||||
type Client struct {
|
||||
rpc officeconvertapiv1connect.ConversionServiceClient
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
pollInterval time.Duration
|
||||
}
|
||||
|
||||
// NewClient creates a new typed Officeconvert client.
|
||||
func NewClient(baseURL string, httpClient *http.Client, options ...connect.ClientOption) *Client {
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
return &Client{
|
||||
rpc: officeconvertapiv1connect.NewConversionServiceClient(httpClient, baseURL, options...),
|
||||
httpClient: httpClient,
|
||||
baseURL: baseURL,
|
||||
pollInterval: defaultPollInterval,
|
||||
}
|
||||
}
|
||||
|
||||
// SetPollInterval configures the polling cadence used by WaitForCompletion.
|
||||
func (c *Client) SetPollInterval(interval time.Duration) {
|
||||
if interval > 0 {
|
||||
c.pollInterval = interval
|
||||
}
|
||||
}
|
||||
|
||||
// CreateConversion starts a conversion session and returns upload metadata.
|
||||
func (c *Client) CreateConversion(
|
||||
ctx context.Context,
|
||||
sourceFilename string,
|
||||
) (*officeconvertapiv1.CreateConversionResponse, error) {
|
||||
req := connect.NewRequest(&officeconvertapiv1.CreateConversionRequest{
|
||||
SourceFilename: sourceFilename,
|
||||
})
|
||||
res, err := c.rpc.CreateConversion(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res.Msg, nil
|
||||
}
|
||||
|
||||
// StartConversion signals that upload is complete and conversion can begin.
|
||||
func (c *Client) StartConversion(
|
||||
ctx context.Context,
|
||||
conversionID string,
|
||||
) (*officeconvertapiv1.StartConversionResponse, error) {
|
||||
req := connect.NewRequest(&officeconvertapiv1.StartConversionRequest{
|
||||
ConversionId: conversionID,
|
||||
})
|
||||
res, err := c.rpc.StartConversion(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res.Msg, nil
|
||||
}
|
||||
|
||||
// GetConversionStatus returns the latest status for a conversion session.
|
||||
func (c *Client) GetConversionStatus(
|
||||
ctx context.Context,
|
||||
conversionID string,
|
||||
) (*officeconvertapiv1.GetConversionStatusResponse, error) {
|
||||
req := connect.NewRequest(&officeconvertapiv1.GetConversionStatusRequest{
|
||||
ConversionId: conversionID,
|
||||
})
|
||||
res, err := c.rpc.GetConversionStatus(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res.Msg, nil
|
||||
}
|
||||
|
||||
// GetSlideDeck retrieves the final converted deck response.
|
||||
func (c *Client) GetSlideDeck(
|
||||
ctx context.Context,
|
||||
conversionID string,
|
||||
) (*officeconvertapiv1.GetSlideDeckResponse, error) {
|
||||
req := connect.NewRequest(&officeconvertapiv1.GetSlideDeckRequest{
|
||||
ConversionId: conversionID,
|
||||
})
|
||||
res, err := c.rpc.GetSlideDeck(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res.Msg, nil
|
||||
}
|
||||
|
||||
// DeleteConversion triggers immediate resource cleanup for a session.
|
||||
func (c *Client) DeleteConversion(
|
||||
ctx context.Context,
|
||||
conversionID string,
|
||||
) (*officeconvertapiv1.DeleteConversionResponse, error) {
|
||||
req := connect.NewRequest(&officeconvertapiv1.DeleteConversionRequest{
|
||||
ConversionId: conversionID,
|
||||
})
|
||||
res, err := c.rpc.DeleteConversion(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res.Msg, nil
|
||||
}
|
||||
|
||||
// WaitForCompletion polls status until terminal completion or context cancellation.
|
||||
func (c *Client) WaitForCompletion(
|
||||
ctx context.Context,
|
||||
conversionID string,
|
||||
) (*officeconvertapiv1.GetConversionStatusResponse, error) {
|
||||
ticker := time.NewTicker(c.pollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
status, err := c.GetConversionStatus(ctx, conversionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch status.Status {
|
||||
case officeconvertapiv1.ConversionStatus_CONVERSION_STATUS_SUCCEEDED:
|
||||
return status, nil
|
||||
case officeconvertapiv1.ConversionStatus_CONVERSION_STATUS_FAILED:
|
||||
return nil, errors.New(status.ErrorMessage)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package officeconvertclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
officeconvertapiv1 "github.com/end/officeconvert/gen/go/officeconvertapi/v1"
|
||||
)
|
||||
|
||||
// DownloadedArtifact records a downloaded slide image destination path.
|
||||
type DownloadedArtifact struct {
|
||||
SlideIndex int32
|
||||
LocalPath string
|
||||
}
|
||||
|
||||
// DownloadArtifacts fetches all slide image URLs from a slide deck into outputDir.
|
||||
func (c *Client) DownloadArtifacts(
|
||||
ctx context.Context,
|
||||
slideDeck *officeconvertapiv1.SlideDeck,
|
||||
outputDir string,
|
||||
) ([]DownloadedArtifact, error) {
|
||||
if slideDeck == nil {
|
||||
return nil, fmt.Errorf("slide deck is nil")
|
||||
}
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create output directory: %w", err)
|
||||
}
|
||||
|
||||
downloaded := make([]DownloadedArtifact, 0, len(slideDeck.Slides))
|
||||
for _, slide := range slideDeck.Slides {
|
||||
localPath := filepath.Join(outputDir, fmt.Sprintf("slide-%04d%s", slide.Index, inferImageExt(slide.ImageUrl)))
|
||||
if err := c.downloadFile(ctx, slide.ImageUrl, localPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
downloaded = append(downloaded, DownloadedArtifact{
|
||||
SlideIndex: slide.Index,
|
||||
LocalPath: localPath,
|
||||
})
|
||||
}
|
||||
|
||||
return downloaded, nil
|
||||
}
|
||||
|
||||
func (c *Client) downloadFile(ctx context.Context, sourceURL string, destinationPath string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build download request: %w", err)
|
||||
}
|
||||
res, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download artifact: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return fmt.Errorf("download failed with status %d", res.StatusCode)
|
||||
}
|
||||
file, err := os.Create(destinationPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create artifact file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(file, res.Body); err != nil {
|
||||
return fmt.Errorf("write artifact file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inferImageExt(imageURL string) string {
|
||||
switch {
|
||||
case strings.Contains(imageURL, ".jpeg") || strings.Contains(imageURL, ".jpg"):
|
||||
return ".jpg"
|
||||
case strings.Contains(imageURL, ".webp"):
|
||||
return ".webp"
|
||||
default:
|
||||
return ".png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package officeconvertclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
officeconvertapiv1 "github.com/end/officeconvert/gen/go/officeconvertapi/v1"
|
||||
)
|
||||
|
||||
// ConversionResult groups the terminal status and deck payload for one conversion.
|
||||
type ConversionResult struct {
|
||||
Status *officeconvertapiv1.GetConversionStatusResponse
|
||||
Deck *officeconvertapiv1.SlideDeck
|
||||
}
|
||||
|
||||
// ConvertPPTXFile runs the full create-upload-start-wait-fetch flow.
|
||||
func (c *Client) ConvertPPTXFile(ctx context.Context, localPPTXPath string) (*ConversionResult, error) {
|
||||
createRes, err := c.CreateConversion(ctx, filepath.Base(localPPTXPath))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create conversion: %w", err)
|
||||
}
|
||||
|
||||
if err := c.UploadPPTX(ctx, createRes.UploadUrl, localPPTXPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := c.StartConversion(ctx, createRes.ConversionId); err != nil {
|
||||
return nil, fmt.Errorf("start conversion: %w", err)
|
||||
}
|
||||
|
||||
statusRes, err := c.WaitForCompletion(ctx, createRes.ConversionId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wait for completion: %w", err)
|
||||
}
|
||||
deckRes, err := c.GetSlideDeck(ctx, createRes.ConversionId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get slide deck: %w", err)
|
||||
}
|
||||
|
||||
return &ConversionResult{
|
||||
Status: statusRes,
|
||||
Deck: deckRes.SlideDeck,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package officeconvertclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// UploadPPTX uploads a local PPTX using the presigned URL from CreateConversion.
|
||||
func (c *Client) UploadPPTX(
|
||||
ctx context.Context,
|
||||
uploadURL string,
|
||||
localPPTXPath string,
|
||||
) error {
|
||||
file, err := os.Open(localPPTXPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open pptx: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
return c.uploadReader(ctx, uploadURL, file)
|
||||
}
|
||||
|
||||
func (c *Client) uploadReader(ctx context.Context, uploadURL string, body io.Reader) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build upload request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/vnd.openxmlformats-officedocument.presentationml.presentation")
|
||||
|
||||
res, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload pptx: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return fmt.Errorf("upload failed with status %d", res.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user