mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Drive-domain errors now leave the CLI as typed, machine-branchable envelopes — a stable `type` plus `subtype` and named fields (param, params, retryable, log_id, hint) — so scripts and AI agents can branch on structure and act on a recovery hint instead of parsing prose. Changes: - Every error produced in the drive domain — validation, file I/O, and the failures returned from its Lark API calls — is emitted as a typed errs.* error; the exit code is derived from the error category. Drive's API calls now go through a shared typed classifier, so failures carry subtype, troubleshooter, a recovery hint, and the request's log_id whether the server returns it in the response body or the x-tt-logid header; an already-typed network/auth error is never downgraded into a generic API error. - Known API conditions (resource conflict, cross-tenant, cross-brand, ...) carry a recovery hint keyed by their error class; a command can refine that hint with command-specific guidance. - Batch partial failures (+push / +pull / +sync, where some items succeed and some fail) now report an honest ok:false multi-status result on stdout — the summary and every per-item outcome stay machine-readable — and exit non-zero, instead of a misleading ok:true success envelope. - Duplicate rel_path conflicts report each colliding path as a structured params entry (RFC 7807 invalid-params style). - Static guards lock the drive path so legacy error construction — direct envelopes or the auto-classifying API helpers — cannot be reintroduced, making drive the template for the remaining domains. Output changes worth noting for consumers: - Error envelopes now carry typed type/subtype and named fields; exit codes follow the error category (malformed or incomplete API responses are reported as internal errors rather than generic API errors). - Batch partial failures (+push / +pull / +sync) emit an ok:false result envelope on stdout (summary + per-item items[]) and exit non-zero; the per-item results stay on stdout rather than in a stderr error envelope. Errors surfaced through shared cross-domain helpers (scope precheck, media import upload, metadata lookup, save-path resolution) are not yet typed; they migrate with the shared layer in a follow-up change.
405 lines
13 KiB
Go
405 lines
13 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package drive
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"path"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
const (
|
|
driveListRemotePageSize = 200
|
|
driveTypeFile = "file"
|
|
driveTypeFolder = "folder"
|
|
driveUniqueSuffixMaxSeq = 1024
|
|
)
|
|
|
|
// driveRemoteEntry is one Drive entry returned by listRemoteFolderEntries. It
|
|
// carries enough metadata for every shortcut that consumes the listing
|
|
// to build its own per-shortcut view by filtering on Type.
|
|
type driveRemoteEntry struct {
|
|
// FileToken is the Drive token for this entry. For type=folder this
|
|
// is the folder_token; for everything else it is the file_token.
|
|
FileToken string
|
|
Name string
|
|
Size int64
|
|
// Type is the Drive entry kind verbatim from the API:
|
|
// "file" | "folder" | "docx" | "doc" | "sheet" | "bitable" |
|
|
// "mindnote" | "slides" | "shortcut" | …
|
|
Type string
|
|
CreatedTime string
|
|
ModifiedTime string
|
|
// RelPath is the entry's path relative to the listing root. Encoded
|
|
// with "/" separators on every platform so it matches the rel_paths
|
|
// produced by the shortcuts' local walkers.
|
|
RelPath string
|
|
}
|
|
|
|
type driveDuplicateRemoteEntry struct {
|
|
FileToken string `json:"file_token"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Size int64 `json:"size,omitempty"`
|
|
CreatedTime string `json:"created_time,omitempty"`
|
|
ModifiedTime string `json:"modified_time,omitempty"`
|
|
}
|
|
|
|
type driveDuplicateRemotePath struct {
|
|
RelPath string `json:"rel_path"`
|
|
Entries []driveDuplicateRemoteEntry `json:"entries"`
|
|
}
|
|
|
|
// listRemoteFolderEntries recursively lists folderToken under relBase and
|
|
// returns one entry per Drive item. Subfolders are descended into and the
|
|
// folder's own entry is also recorded, allowing callers to detect multiple
|
|
// remote files that map to the same rel_path.
|
|
//
|
|
// The helper deliberately stores every Drive object kind. Online docs and
|
|
// shortcuts are skipped by sync shortcuts later, but preserving their rel_path
|
|
// here prevents destructive mirror modes from treating a local same-named
|
|
// regular file as an orphan when Drive already owns that path.
|
|
//
|
|
// Pagination uses common.PaginationMeta, which accepts both page_token and
|
|
// next_page_token.
|
|
func listRemoteFolderEntries(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) ([]driveRemoteEntry, error) {
|
|
var out []driveRemoteEntry
|
|
pageToken := ""
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
params := map[string]interface{}{
|
|
"folder_token": folderToken,
|
|
"page_size": fmt.Sprint(driveListRemotePageSize),
|
|
}
|
|
if pageToken != "" {
|
|
params["page_token"] = pageToken
|
|
}
|
|
result, err := runtime.CallAPITyped("GET", "/open-apis/drive/v1/files", params, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rawFiles, _ := result["files"].([]interface{})
|
|
for _, item := range rawFiles {
|
|
f, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
fType := common.GetString(f, "type")
|
|
fName := common.GetString(f, "name")
|
|
fToken := common.GetString(f, "token")
|
|
if fName == "" || fToken == "" {
|
|
continue
|
|
}
|
|
rel := joinRelDrive(relBase, fName)
|
|
out = append(out, driveRemoteEntry{
|
|
FileToken: fToken,
|
|
Name: fName,
|
|
Size: int64(common.GetFloat(f, "size")),
|
|
Type: fType,
|
|
CreatedTime: common.GetString(f, "created_time"),
|
|
ModifiedTime: common.GetString(f, "modified_time"),
|
|
RelPath: rel,
|
|
})
|
|
if fType == driveTypeFolder {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
sub, err := listRemoteFolderEntries(ctx, runtime, fToken, rel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, sub...)
|
|
}
|
|
}
|
|
hasMore, nextToken := common.PaginationMeta(result)
|
|
if !hasMore || nextToken == "" {
|
|
break
|
|
}
|
|
pageToken = nextToken
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func duplicateRemoteFilePaths(entries []driveRemoteEntry) []driveDuplicateRemotePath {
|
|
groups := make(map[string][]driveRemoteEntry)
|
|
for _, entry := range entries {
|
|
groups[entry.RelPath] = append(groups[entry.RelPath], entry)
|
|
}
|
|
|
|
relPaths := make([]string, 0, len(groups))
|
|
for relPath, grouped := range groups {
|
|
if len(grouped) > 1 {
|
|
relPaths = append(relPaths, relPath)
|
|
}
|
|
}
|
|
sort.Strings(relPaths)
|
|
|
|
duplicates := make([]driveDuplicateRemotePath, 0, len(relPaths))
|
|
for _, relPath := range relPaths {
|
|
grouped := append([]driveRemoteEntry(nil), groups[relPath]...)
|
|
sort.SliceStable(grouped, func(i, j int) bool {
|
|
if grouped[i].Type != grouped[j].Type {
|
|
return grouped[i].Type < grouped[j].Type
|
|
}
|
|
if cmp, ok := compareDriveTimes(grouped[i].CreatedTime, grouped[j].CreatedTime); ok && cmp != 0 {
|
|
return cmp < 0
|
|
}
|
|
if cmp, ok := compareDriveTimes(grouped[i].ModifiedTime, grouped[j].ModifiedTime); ok && cmp != 0 {
|
|
return cmp < 0
|
|
}
|
|
return grouped[i].FileToken < grouped[j].FileToken
|
|
})
|
|
dupEntries := make([]driveDuplicateRemoteEntry, 0, len(grouped))
|
|
for _, entry := range grouped {
|
|
dupEntries = append(dupEntries, driveDuplicateRemoteEntry{
|
|
FileToken: entry.FileToken,
|
|
Name: entry.Name,
|
|
Type: entry.Type,
|
|
Size: entry.Size,
|
|
CreatedTime: entry.CreatedTime,
|
|
ModifiedTime: entry.ModifiedTime,
|
|
})
|
|
}
|
|
duplicates = append(duplicates, driveDuplicateRemotePath{RelPath: relPath, Entries: dupEntries})
|
|
}
|
|
return duplicates
|
|
}
|
|
|
|
// duplicateRemotePathError reports that multiple Drive entries resolve to the
|
|
// same rel_path. Each colliding rel_path becomes one InvalidParam whose Name is
|
|
// the rel_path and whose Reason enumerates the colliding entries (type +
|
|
// file_token), so an AI agent reading the typed envelope can identify exactly
|
|
// which Drive objects collide without re-listing the folder.
|
|
func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) error {
|
|
params := make([]errs.InvalidParam, 0, len(duplicates))
|
|
for _, d := range duplicates {
|
|
descriptions := make([]string, 0, len(d.Entries))
|
|
for _, entry := range d.Entries {
|
|
descriptions = append(descriptions, fmt.Sprintf("%s %s", entry.Type, entry.FileToken))
|
|
}
|
|
params = append(params, errs.InvalidParam{
|
|
Name: d.RelPath,
|
|
Reason: fmt.Sprintf("%d Drive entries collide here: %s", len(d.Entries), strings.Join(descriptions, ", ")),
|
|
})
|
|
}
|
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
|
"%d rel_path(s) map to multiple Drive entries", len(duplicates)).
|
|
WithHint("resolve the duplicate remote files first: re-run +pull with --on-duplicate-remote=rename (downloads each with a hashed suffix), or use --on-duplicate-remote=newest|oldest (supported by +pull/+sync/+push) to pick one, or delete the extra remote files; a plain retry will not help").
|
|
WithParams(params...)
|
|
}
|
|
|
|
const (
|
|
driveDuplicateRemoteFail = "fail"
|
|
driveDuplicateRemoteRename = "rename"
|
|
driveDuplicateRemoteNewest = "newest"
|
|
driveDuplicateRemoteOldest = "oldest"
|
|
)
|
|
|
|
// sortRemoteFiles orders duplicate Drive files according to the conflict
|
|
// strategy, using parsed Drive timestamps so mixed second/millisecond/
|
|
// microsecond epochs compare by actual time rather than raw integer width.
|
|
func sortRemoteFiles(files []driveRemoteEntry, strategy string) {
|
|
sort.SliceStable(files, func(i, j int) bool {
|
|
a, b := files[i], files[j]
|
|
switch strategy {
|
|
case driveDuplicateRemoteNewest:
|
|
if cmp, ok := compareDriveTimes(a.ModifiedTime, b.ModifiedTime); ok && cmp != 0 {
|
|
return cmp > 0
|
|
} else if !ok {
|
|
return a.FileToken < b.FileToken
|
|
}
|
|
if cmp, ok := compareDriveTimes(a.CreatedTime, b.CreatedTime); ok && cmp != 0 {
|
|
return cmp > 0
|
|
} else if !ok {
|
|
return a.FileToken < b.FileToken
|
|
}
|
|
default:
|
|
if cmp, ok := compareDriveTimes(a.CreatedTime, b.CreatedTime); ok && cmp != 0 {
|
|
return cmp < 0
|
|
} else if !ok {
|
|
return a.FileToken < b.FileToken
|
|
}
|
|
if cmp, ok := compareDriveTimes(a.ModifiedTime, b.ModifiedTime); ok && cmp != 0 {
|
|
return cmp < 0
|
|
} else if !ok {
|
|
return a.FileToken < b.FileToken
|
|
}
|
|
}
|
|
return a.FileToken < b.FileToken
|
|
})
|
|
}
|
|
|
|
// compareDriveTimes compares two Drive epoch strings after normalizing their
|
|
// unit (seconds, milliseconds, or microseconds) into time.Time values.
|
|
func compareDriveTimes(a, b string) (int, bool) {
|
|
at, _, aOK := parseDriveEpoch(a)
|
|
bt, _, bOK := parseDriveEpoch(b)
|
|
if !aOK || !bOK {
|
|
return 0, false
|
|
}
|
|
switch {
|
|
case at.Before(bt):
|
|
return -1, true
|
|
case at.After(bt):
|
|
return 1, true
|
|
default:
|
|
return 0, true
|
|
}
|
|
}
|
|
|
|
func parseDriveEpoch(raw string) (time.Time, time.Duration, bool) {
|
|
v, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil {
|
|
return time.Time{}, 0, false
|
|
}
|
|
// Drive timestamps are epoch strings. The API currently returns
|
|
// milliseconds, but tests and older payloads may still use seconds.
|
|
// Infer the unit conservatively from magnitude and compare local mtimes
|
|
// at the same resolution so sub-second filesystem noise does not force
|
|
// a transfer in smart mode.
|
|
switch {
|
|
case v > 1e14 || v < -1e14:
|
|
return time.UnixMicro(v), time.Microsecond, true
|
|
case v > 1e11 || v < -1e11:
|
|
return time.UnixMilli(v), time.Millisecond, true
|
|
default:
|
|
return time.Unix(v, 0), time.Second, true
|
|
}
|
|
}
|
|
|
|
// compareDriveRemoteModifiedToLocal compares one Drive modified_time string to a
|
|
// local file mtime.
|
|
// - returns -1 when remote < local
|
|
// - returns 0 when remote == local at the remote timestamp resolution
|
|
// - returns 1 when remote > local
|
|
//
|
|
// The bool reports whether the remote timestamp was parseable.
|
|
func compareDriveRemoteModifiedToLocal(remoteModified string, local time.Time) (int, bool) {
|
|
remoteTime, resolution, ok := parseDriveEpoch(remoteModified)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
localAtRemoteResolution := local.Truncate(resolution)
|
|
switch {
|
|
case remoteTime.Before(localAtRemoteResolution):
|
|
return -1, true
|
|
case remoteTime.After(localAtRemoteResolution):
|
|
return 1, true
|
|
default:
|
|
return 0, true
|
|
}
|
|
}
|
|
|
|
func chooseRemoteFile(files []driveRemoteEntry, strategy string) (driveRemoteEntry, error) {
|
|
if len(files) == 0 {
|
|
return driveRemoteEntry{}, errs.NewInternalError(errs.SubtypeUnknown, "no Drive entries available for strategy %q", strategy)
|
|
}
|
|
candidates := append([]driveRemoteEntry(nil), files...)
|
|
sortRemoteFiles(candidates, strategy)
|
|
return candidates[0], nil
|
|
}
|
|
|
|
func isFileOnlyDuplicatePath(duplicate driveDuplicateRemotePath) bool {
|
|
if len(duplicate.Entries) < 2 {
|
|
return false
|
|
}
|
|
for _, entry := range duplicate.Entries {
|
|
if entry.Type != driveTypeFile {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func blockingRemotePathConflicts(entries []driveRemoteEntry, duplicateRemote string) []driveDuplicateRemotePath {
|
|
duplicates := duplicateRemoteFilePaths(entries)
|
|
if duplicateRemote == driveDuplicateRemoteFail {
|
|
return duplicates
|
|
}
|
|
blocking := make([]driveDuplicateRemotePath, 0, len(duplicates))
|
|
for _, duplicate := range duplicates {
|
|
if !isFileOnlyDuplicatePath(duplicate) {
|
|
blocking = append(blocking, duplicate)
|
|
}
|
|
}
|
|
return blocking
|
|
}
|
|
|
|
func occupiedRemotePaths(entries []driveRemoteEntry) map[string]struct{} {
|
|
occupied := make(map[string]struct{}, len(entries))
|
|
for _, entry := range entries {
|
|
occupied[entry.RelPath] = struct{}{}
|
|
}
|
|
return occupied
|
|
}
|
|
|
|
func stableTokenHash(fileToken string) string {
|
|
sum := sha256.Sum256([]byte(fileToken))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func stableTokenIdentifier(fileToken string) string {
|
|
hash := stableTokenHash(fileToken)
|
|
if len(hash) > 12 {
|
|
hash = hash[:12]
|
|
}
|
|
return "hash_" + hash
|
|
}
|
|
|
|
func relPathWithSuffix(relPath, suffix string) string {
|
|
dir, base := path.Split(relPath)
|
|
ext := path.Ext(base)
|
|
if ext == base {
|
|
return dir + base + suffix
|
|
}
|
|
stem := base[:len(base)-len(ext)]
|
|
return dir + stem + suffix + ext
|
|
}
|
|
|
|
func relPathWithUniqueFileTokenSuffix(relPath, fileToken string, occupied map[string]struct{}) (string, error) {
|
|
tokenHash := stableTokenHash(fileToken)
|
|
suffixes := []string{
|
|
"__lark_" + tokenHash[:12],
|
|
"__lark_" + tokenHash[:24],
|
|
"__lark_" + tokenHash,
|
|
}
|
|
for _, suffix := range suffixes {
|
|
candidate := relPathWithSuffix(relPath, suffix)
|
|
if _, exists := occupied[candidate]; !exists {
|
|
occupied[candidate] = struct{}{}
|
|
return candidate, nil
|
|
}
|
|
}
|
|
for attempt := 2; attempt <= driveUniqueSuffixMaxSeq; attempt++ {
|
|
candidate := relPathWithSuffix(relPath, "__lark_"+tokenHash+"_"+strconv.Itoa(attempt))
|
|
if _, exists := occupied[candidate]; !exists {
|
|
occupied[candidate] = struct{}{}
|
|
return candidate, nil
|
|
}
|
|
}
|
|
return "", errs.NewInternalError(errs.SubtypeUnknown, "could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq)
|
|
}
|
|
|
|
// joinRelDrive joins a rel_path base with an entry name using "/".
|
|
// Empty base means the entry sits at the listing root. Mirrors the
|
|
// behavior the per-shortcut helpers used to ship and keeps rel_paths
|
|
// stable across +status / +pull / +push.
|
|
func joinRelDrive(base, name string) string {
|
|
if base == "" {
|
|
return name
|
|
}
|
|
return base + "/" + name
|
|
}
|