mvp implementation

This commit is contained in:
2026-03-26 14:01:10 -07:00
parent 0cde587220
commit ebcf404fde
33 changed files with 3048 additions and 6 deletions
+12
View File
@@ -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 => ../..
+6
View File
@@ -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=
+147
View File
@@ -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
}
+43
View File
@@ -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
}