refactor: migrate vc/minutes shortcuts to FileIO (#336)

* refactor: migrate vc/minutes shortcuts to FileIO

- vc_notes: replace vfs.Stat + validate.SafeOutputPath + validate.AtomicWrite
  with FileIO.Stat/Save for transcript download
- minutes_download: replace validate.SafeOutputPath + validate.AtomicWriteFromReader
  with FileIO.Save, use FileIO.Stat for overwrite checks
- Use WrapSaveError to preserve original error messages
This commit is contained in:
tuxedomm
2026-04-08 19:34:19 +08:00
committed by GitHub
parent 555722ac8e
commit 63ea52b2e6
3 changed files with 55 additions and 34 deletions

View File

@@ -363,6 +363,24 @@ func WrapOpenError(err error, pathMsg, readMsg string) error {
return fmt.Errorf("%s: %w", readMsg, err)
}
// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors,
// using standardized messages and the given error category (e.g. "api_error", "io").
// Path validation errors always use ErrValidation (exit code 2).
func WrapSaveErrorByCategory(err error, category string) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return output.ErrValidation("unsafe output path: %s", err)
case errors.As(err, &me):
return output.Errorf(output.ExitInternal, category, "cannot create parent directory: %s", err)
default:
return output.Errorf(output.ExitInternal, category, "cannot create file: %s", err)
}
}
// ValidatePath checks that path is a valid relative input path within the
// working directory by delegating to FileIO.Stat. Returns nil if the path is
// valid or does not exist yet; returns an error only for illegal paths

View File

@@ -14,8 +14,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -79,7 +78,7 @@ var MinutesDownload = common.Shortcut{
// Batch mode: --output must be a directory, not an existing file.
if !single && outputPath != "" {
if fi, err := vfs.Stat(outputPath); err == nil && !fi.IsDir() {
if fi, err := runtime.FileIO().Stat(outputPath); err == nil && !fi.IsDir() {
return output.ErrValidation("--output %q is a file; batch mode expects a directory path", outputPath)
}
}
@@ -162,7 +161,7 @@ var MinutesDownload = common.Shortcut{
fmt.Fprintf(errOut, "Downloading media: %s\n", common.MaskToken(token))
// single token: --output is a file path; batch: --output is a directory
opts := downloadOpts{overwrite: overwrite, usedNames: usedNames}
opts := downloadOpts{fio: runtime.FileIO(), overwrite: overwrite, usedNames: usedNames}
if single {
opts.outputPath = outputPath
} else {
@@ -229,8 +228,9 @@ type downloadResult struct {
}
type downloadOpts struct {
outputPath string // explicit output file path (single mode only)
outputDir string // output directory (batch mode)
fio fileio.FileIO // file I/O abstraction
outputPath string // explicit output file path (single mode only)
outputDir string // output directory (batch mode)
overwrite bool
usedNames map[string]bool // tracks used filenames to deduplicate in batch mode
}
@@ -275,22 +275,24 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
outputPath = filepath.Join(opts.outputDir, filename)
}
safePath, err := validate.SafeOutputPath(outputPath)
if err != nil {
return nil, output.ErrValidation("unsafe output path: %s", err)
}
if err := common.EnsureWritableFile(safePath, opts.overwrite); err != nil {
return nil, err
}
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err)
if !opts.overwrite {
if _, statErr := opts.fio.Stat(outputPath); statErr == nil {
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
}
}
sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600)
result, err := opts.fio.Save(outputPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err)
return nil, common.WrapSaveErrorByCategory(err, "io")
}
return &downloadResult{savedPath: safePath, sizeBytes: sizeBytes}, nil
resolvedPath, err := opts.fio.ResolvePath(outputPath)
if err != nil || resolvedPath == "" {
resolvedPath = outputPath
}
return &downloadResult{savedPath: resolvedPath, sizeBytes: result.Size()}, nil
}
// resolveFilenameFromResponse derives the filename from HTTP response headers.

View File

@@ -11,8 +11,10 @@
package vc
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -22,11 +24,11 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -262,25 +264,16 @@ func downloadTranscriptFile(runtime *common.RuntimeContext, minuteToken string,
base = outDir
}
dirName := filepath.Join(base, sanitizeDirName(title, minuteToken))
transcriptPath := filepath.Join(dirName, "transcript.txt")
// Overwrite check via FileIO.Stat
if !runtime.Bool("overwrite") {
transcriptPath := filepath.Join(dirName, "transcript.txt")
if _, statErr := vfs.Stat(transcriptPath); statErr == nil {
if _, statErr := runtime.FileIO().Stat(transcriptPath); statErr == nil {
fmt.Fprintf(errOut, "%s transcript already exists: %s (use --overwrite to replace)\n", logPrefix, transcriptPath)
return transcriptPath
}
}
transcriptPath := filepath.Join(dirName, "transcript.txt")
safePath, err := validate.SafeOutputPath(transcriptPath)
if err != nil {
fmt.Fprintf(errOut, "%s invalid transcript path: %v\n", logPrefix, err)
return ""
}
if err := vfs.MkdirAll(filepath.Dir(safePath), 0755); err != nil {
fmt.Fprintf(errOut, "%s failed to create directory: %v\n", logPrefix, err)
return ""
}
fmt.Fprintf(errOut, "%s downloading transcript: %s\n", logPrefix, transcriptPath)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
@@ -303,8 +296,16 @@ func downloadTranscriptFile(runtime *common.RuntimeContext, minuteToken string,
fmt.Fprintf(errOut, "%s transcript is empty (not available for this minute)\n", logPrefix)
return ""
}
if err := validate.AtomicWrite(safePath, apiResp.RawBody, 0644); err != nil {
fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err)
if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(apiResp.RawBody)); err != nil {
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
fmt.Fprintf(errOut, "%s invalid transcript path: %v\n", logPrefix, err)
case errors.As(err, &me):
fmt.Fprintf(errOut, "%s failed to create directory: %v\n", logPrefix, err)
default:
fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err)
}
return ""
}
return transcriptPath