Files
larksuite-cli/shortcuts/drive/drive_version.go
wangweiming-01 241952459d feat: add drive version shortcut (#841)
Change-Id: I87bb32c86e3c3362f541ccc6320c656eb795ec9b
2026-05-18 16:44:10 +08:00

455 lines
15 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"io"
"math"
"net/http"
"path/filepath"
"regexp"
"strconv"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveVersionNumberRe = regexp.MustCompile(`^\d{1,19}$`)
type driveVersionHistorySpec struct {
FileToken string
Limit int
Cursor string
}
func validateDriveNumericValue(value, flagName, valueLabel string) error {
value = strings.TrimSpace(value)
if value == "" {
return output.ErrValidation("%s cannot be empty", flagName)
}
if !driveVersionNumberRe.MatchString(value) {
return output.ErrValidation("%s must be a numeric %s", flagName, valueLabel)
}
return nil
}
func validateDriveVersionValue(value, flagName string) error {
return validateDriveNumericValue(value, flagName, "version string")
}
func validateDriveCursorValue(value, flagName string) error {
return validateDriveNumericValue(value, flagName, "pagination cursor")
}
func validateDriveVersionHistorySpec(spec driveVersionHistorySpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
if spec.Limit < 1 || spec.Limit > 200 {
return output.ErrValidation("invalid --limit %d: must be between 1 and 200", spec.Limit)
}
if spec.Cursor != "" {
if err := validateDriveCursorValue(spec.Cursor, "--cursor"); err != nil {
return err
}
}
return nil
}
func driveVersionHistoryParams(spec driveVersionHistorySpec) map[string]interface{} {
params := map[string]interface{}{
"only_tag": true,
"page_size": spec.Limit,
}
if spec.Cursor != "" {
params["last_edit_time"] = spec.Cursor
}
return params
}
func driveVersionActionTypeLabel(raw int) string {
switch raw {
case 1:
return "upload"
case 2:
return "rename"
case 3:
return "delete_version"
case 4:
return "revert"
default:
return fmt.Sprintf("type_%d", raw)
}
}
func driveVersionFieldString(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
if s := common.GetString(m, key); s != "" {
return s
}
f, ok := util.ToFloat64(m[key])
if !ok || math.IsInf(f, 0) || math.IsNaN(f) {
return ""
}
if math.Trunc(f) == f {
return strconv.FormatInt(int64(f), 10)
}
return strconv.FormatFloat(f, 'f', -1, 64)
}
func transformDriveVersionHistory(items []interface{}) []map[string]interface{} {
versions := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
version := common.GetString(m, "version")
if version == "" {
continue
}
versions = append(versions, map[string]interface{}{
"version": version,
"name": common.GetString(m, "name"),
"edited_at": driveVersionFieldString(m, "edit_time"),
"edited_by": common.GetString(m, "edit_user_id"),
"size_bytes": int64(common.GetFloat(m, "size")),
"action_type": driveVersionActionTypeLabel(int(common.GetFloat(m, "type"))),
"is_deleted": common.GetBool(m, "is_deleted"),
"tag": int(common.GetFloat(m, "tag")),
})
}
return versions
}
func nextDriveVersionCursor(items []interface{}, hasMore bool) string {
if !hasMore || len(items) == 0 {
return ""
}
last, _ := items[len(items)-1].(map[string]interface{})
return driveVersionFieldString(last, "edit_time")
}
var DriveVersionHistory = common.Shortcut{
Service: "drive",
Command: "+version-history",
Description: "List the version history of a Drive file",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "file-token", Desc: "target file token", Required: true},
{Name: "limit", Desc: "max versions to return (1-200)", Type: "int", Default: "20"},
{Name: "cursor", Desc: "pagination cursor from the previous page's next_cursor"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveVersionHistorySpec(driveVersionHistorySpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Limit: runtime.Int("limit"),
Cursor: strings.TrimSpace(runtime.Str("cursor")),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := driveVersionHistorySpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Limit: runtime.Int("limit"),
Cursor: strings.TrimSpace(runtime.Str("cursor")),
}
return common.NewDryRunAPI().
Desc("Query version history with only_tag=true and optional pagination cursor").
GET("/open-apis/drive/v1/files/:file_token/history").
Set("file_token", spec.FileToken).
Params(driveVersionHistoryParams(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveVersionHistorySpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Limit: runtime.Int("limit"),
Cursor: strings.TrimSpace(runtime.Str("cursor")),
}
data, err := runtime.CallAPI(
http.MethodGet,
fmt.Sprintf("/open-apis/drive/v1/files/%s/history", validate.EncodePathSegment(spec.FileToken)),
driveVersionHistoryParams(spec),
nil,
)
if err != nil {
return err
}
items := common.GetSlice(data, "items")
hasMore := common.GetBool(data, "has_more")
out := map[string]interface{}{
"versions": transformDriveVersionHistory(items),
"has_more": hasMore,
}
if nextCursor := nextDriveVersionCursor(items, hasMore); nextCursor != "" {
out["next_cursor"] = nextCursor
}
runtime.OutFormat(out, nil, nil)
return nil
},
}
type driveVersionGetSpec struct {
FileToken string
Version string
Output string
Overwrite bool
}
func validateDriveVersionGetSpec(runtime *common.RuntimeContext, spec driveVersionGetSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
if err := validateDriveVersionValue(spec.Version, "--version"); err != nil {
return err
}
if spec.Output == "" {
return nil
}
if _, err := validate.SafeOutputPath(spec.Output); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
return nil
}
func driveVersionGetOutputIsDirectory(runtime *common.RuntimeContext, outputPath string) bool {
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\") {
return true
}
info, err := runtime.FileIO().Stat(outputPath)
return err == nil && info.IsDir()
}
func prettyPrintDriveVersionSavedFile(w io.Writer, data map[string]interface{}) {
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
fmt.Fprintf(w, "version: %s\n", common.GetString(data, "version"))
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
fmt.Fprintf(w, "saved_path: %s\n", common.GetString(data, "saved_path"))
fmt.Fprintf(w, "size_bytes: %d\n", int64(common.GetFloat(data, "size_bytes")))
}
var DriveVersionGet = common.Shortcut{
Service: "drive",
Command: "+version-get",
Description: "Download a specific version of a Drive file",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "file-token", Desc: "target file token", Required: true},
{Name: "version", Desc: "version from drive +version-history (not tag)", Required: true},
{Name: "output", Desc: "local save path or directory; omit to save into the current directory using the server filename"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveVersionGetSpec(runtime, driveVersionGetSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Version: strings.TrimSpace(runtime.Str("version")),
Output: strings.TrimSpace(runtime.Str("output")),
Overwrite: runtime.Bool("overwrite"),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := driveVersionGetSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Version: strings.TrimSpace(runtime.Str("version")),
Output: strings.TrimSpace(runtime.Str("output")),
}
outputPath := spec.Output
if outputPath == "" {
outputPath = "."
}
return common.NewDryRunAPI().
Desc("Download a specific file version; when --output is omitted the CLI saves into the current directory using the server filename").
GET("/open-apis/drive/v1/files/:file_token/download").
Set("file_token", spec.FileToken).
Set("output", outputPath).
Params(map[string]interface{}{"version": spec.Version})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveVersionGetSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Version: strings.TrimSpace(runtime.Str("version")),
Output: strings.TrimSpace(runtime.Str("output")),
Overwrite: runtime.Bool("overwrite"),
}
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(spec.FileToken)),
QueryParams: larkcore.QueryParams{
"version": []string{spec.Version},
},
})
if err != nil {
return output.ErrNetwork("download failed: %s", err)
}
defer resp.Body.Close()
fileName := common.ResolveDownloadFileName(resp.Header, spec.FileToken)
fileName, _ = common.AutoAppendDownloadExtension(fileName, resp.Header, "")
outputPath := spec.Output
if outputPath == "" {
outputPath = "."
}
if driveVersionGetOutputIsDirectory(runtime, outputPath) {
outputPath = filepath.Join(outputPath, fileName)
} else {
outputPath, _ = common.AutoAppendDownloadExtension(outputPath, resp.Header, "")
}
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
return output.ErrValidation("unsafe output path: %s", resolveErr)
}
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !spec.Overwrite {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
}
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
}
savedPath, _ := runtime.ResolveSavePath(outputPath)
if savedPath == "" {
savedPath = outputPath
}
out := map[string]interface{}{
"file_token": spec.FileToken,
"version": spec.Version,
"file_name": filepath.Base(outputPath),
"saved_path": savedPath,
"size_bytes": result.Size(),
}
runtime.OutFormat(out, nil, func(w io.Writer) {
prettyPrintDriveVersionSavedFile(w, out)
})
return nil
},
}
type driveVersionMutationSpec struct {
FileToken string
Version string
}
func validateDriveVersionMutationSpec(spec driveVersionMutationSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
return validateDriveVersionValue(spec.Version, "--version")
}
var DriveVersionRevert = common.Shortcut{
Service: "drive",
Command: "+version-revert",
Description: "Revert a Drive file to a specific historical version",
Risk: "write",
Scopes: []string{"drive:file:upload"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "target file token", Required: true},
{Name: "version", Desc: "version from drive +version-history to revert to (not tag)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveVersionMutationSpec(driveVersionMutationSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Version: strings.TrimSpace(runtime.Str("version")),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := driveVersionMutationSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Version: strings.TrimSpace(runtime.Str("version")),
}
return common.NewDryRunAPI().
Desc("Revert the current file to a specified historical version").
POST("/open-apis/drive/v1/files/:file_token/revert").
Set("file_token", spec.FileToken).
Body(map[string]interface{}{"version": spec.Version})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveVersionMutationSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Version: strings.TrimSpace(runtime.Str("version")),
}
if _, err := runtime.CallAPI(
http.MethodPost,
fmt.Sprintf("/open-apis/drive/v1/files/%s/revert", validate.EncodePathSegment(spec.FileToken)),
nil,
map[string]interface{}{"version": spec.Version},
); err != nil {
return err
}
runtime.Out(map[string]interface{}{}, nil)
return nil
},
}
var DriveVersionDelete = common.Shortcut{
Service: "drive",
Command: "+version-delete",
Description: "Delete a specific historical version of a Drive file",
Risk: "high-risk-write",
Scopes: []string{"drive:file:upload"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "target file token", Required: true},
{Name: "version", Desc: "version from drive +version-history to delete (not tag)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveVersionMutationSpec(driveVersionMutationSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Version: strings.TrimSpace(runtime.Str("version")),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := driveVersionMutationSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Version: strings.TrimSpace(runtime.Str("version")),
}
return common.NewDryRunAPI().
Desc("Permanently delete a historical file version").
POST("/open-apis/drive/v1/files/:file_token/version_del").
Set("file_token", spec.FileToken).
Body(map[string]interface{}{"version": spec.Version})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveVersionMutationSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Version: strings.TrimSpace(runtime.Str("version")),
}
if _, err := runtime.CallAPI(
http.MethodPost,
fmt.Sprintf("/open-apis/drive/v1/files/%s/version_del", validate.EncodePathSegment(spec.FileToken)),
nil,
map[string]interface{}{"version": spec.Version},
); err != nil {
return err
}
runtime.Out(map[string]interface{}{}, nil)
return nil
},
}