mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 08:12:36 +08:00
Input pre-check failures shared by every shortcut — @file/stdin input resolution, enum validation, and unsupported --dry-run — now leave the CLI as typed validation envelopes naming the offending flag, so scripts and AI agents can branch on `param` instead of parsing prose. Wire type, exit code, and message text are unchanged; the new fields are additive. The shared layer also gains typed replacements for its legacy error-producing helpers, so each business domain can migrate to typed errors without rebuilding common plumbing, and a path-scoped lint guard keeps migrated domains from sliding back. Changes: - Shared pre-check failures (input flags, enum values, dry-run support) return typed validation errors carrying the offending flag as `param`. - Every legacy error-producing helper in shortcuts/common has a typed replacement that preserves the existing message text: validation and flag-group checks, chat/user ID validation (callers name the flag so `param` is ground truth), "me" open-id resolution, safe-path checks, input-stat and save-error wrapping. Legacy helpers stay for not-yet-migrated domains, marked deprecated — including the legacy API-result classifier, whose typed route is runtime.CallAPITyped. - A new errscontract rule rejects legacy common-helper calls on migrated paths, so a migrated domain cannot silently reintroduce legacy envelopes; drive is the first locked path and its last legacy ID-helper calls are replaced.
194 lines
5.1 KiB
Go
194 lines
5.1 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package common
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/internal/util"
|
|
)
|
|
|
|
func FormatSize(bytes int64) string {
|
|
if bytes < 1024 {
|
|
return fmt.Sprintf("%d B", bytes)
|
|
}
|
|
if bytes < 1024*1024 {
|
|
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
|
|
}
|
|
if bytes < 1024*1024*1024 {
|
|
return fmt.Sprintf("%.1f MB", float64(bytes)/1024/1024)
|
|
}
|
|
return fmt.Sprintf("%.1f GB", float64(bytes)/1024/1024/1024)
|
|
}
|
|
|
|
func MaskToken(token string) string {
|
|
if len(token) < 2 {
|
|
return "***"
|
|
}
|
|
if len(token) <= 8 {
|
|
return token[:2] + "***"
|
|
}
|
|
return token[:4] + "..." + token[len(token)-4:]
|
|
}
|
|
|
|
// ParseTime converts time expressions to Unix seconds string.
|
|
//
|
|
// Optional hint: "end" makes day-granularity inputs snap to 23:59:59 instead of 00:00:00.
|
|
//
|
|
// ParseTime("2026-01-01") → 2026-01-01 00:00:00
|
|
// ParseTime("2026-01-01", "end") → 2026-01-01 23:59:59
|
|
//
|
|
// Supported formats: ISO 8601 (with or without time/timezone), date-only, Unix timestamp.
|
|
func ParseTime(input string, hint ...string) (string, error) {
|
|
input = strings.TrimSpace(input)
|
|
isEnd := len(hint) > 0 && hint[0] == "end"
|
|
|
|
// snapDay aligns to start-of-day or end-of-day based on hint.
|
|
snapDay := func(t time.Time) time.Time {
|
|
if isEnd {
|
|
return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location())
|
|
}
|
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
|
}
|
|
|
|
// ISO 8601 with timezone (precise)
|
|
tzFormats := []string{
|
|
time.RFC3339,
|
|
"2006-01-02T15:04Z07:00",
|
|
"2006-01-02T15:04:05Z07:00",
|
|
}
|
|
for _, f := range tzFormats {
|
|
if t, err := time.Parse(f, input); err == nil {
|
|
return fmt.Sprintf("%d", t.Unix()), nil
|
|
}
|
|
}
|
|
// ISO 8601 without timezone — with time component (precise)
|
|
preciseFormats := []string{
|
|
"2006-01-02T15:04:05",
|
|
"2006-01-02 15:04:05",
|
|
"2006-01-02T15:04",
|
|
"2006-01-02 15:04",
|
|
}
|
|
for _, f := range preciseFormats {
|
|
if t, err := time.ParseInLocation(f, input, time.Local); err == nil {
|
|
return fmt.Sprintf("%d", t.Unix()), nil
|
|
}
|
|
}
|
|
// Date-only (day-granularity)
|
|
if t, err := time.ParseInLocation("2006-01-02", input, time.Local); err == nil {
|
|
return fmt.Sprintf("%d", snapDay(t).Unix()), nil
|
|
}
|
|
// Unix timestamp (precise, passed through as-is) — must be purely numeric
|
|
var ts int64
|
|
if n, err := fmt.Sscanf(input, "%d", &ts); err == nil && n == 1 && ts > 0 && fmt.Sprintf("%d", ts) == input {
|
|
return input, nil
|
|
}
|
|
return "", fmt.Errorf("cannot parse time %q (supported: ISO 8601 e.g. 2026-01-01 / 2026-01-01T15:04:05+08:00, Unix timestamp)", input)
|
|
}
|
|
|
|
// FormatTimeWithSeconds converts Unix seconds/ms string to local time string with seconds precision.
|
|
func FormatTimeWithSeconds(ts interface{}) string {
|
|
if ts == nil {
|
|
return ""
|
|
}
|
|
s := fmt.Sprintf("%v", ts)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
var n int64
|
|
fmt.Sscanf(s, "%d", &n)
|
|
if n == 0 {
|
|
return s
|
|
}
|
|
if n > 1e12 {
|
|
n = n / 1000
|
|
}
|
|
t := time.Unix(n, 0)
|
|
return t.Local().Format("2006-01-02 15:04:05")
|
|
}
|
|
|
|
// FormatTime converts Unix seconds/ms string to local time string.
|
|
func FormatTime(ts interface{}) string {
|
|
if ts == nil {
|
|
return ""
|
|
}
|
|
s := fmt.Sprintf("%v", ts)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
var n int64
|
|
fmt.Sscanf(s, "%d", &n)
|
|
if n == 0 {
|
|
return s
|
|
}
|
|
// Detect ms vs seconds
|
|
if n > 1e12 {
|
|
n = n / 1000
|
|
}
|
|
t := time.Unix(n, 0)
|
|
return t.Local().Format("2006-01-02 15:04")
|
|
}
|
|
|
|
// SplitCSV 解析逗号分隔的列表,忽略空项并去除空格
|
|
func SplitCSV(input string) []string {
|
|
if input == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(input, ",")
|
|
var result []string
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
result = append(result, p)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// CheckApiError checks if API result is an error and prints it to w.
|
|
func CheckApiError(w io.Writer, result interface{}, action string) bool {
|
|
if resultMap, ok := result.(map[string]interface{}); ok {
|
|
code, _ := util.ToFloat64(resultMap["code"])
|
|
if code != 0 {
|
|
msg, _ := resultMap["msg"].(string)
|
|
output.PrintError(w, fmt.Sprintf("%s: [%.0f] %s", action, code, msg))
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HandleApiResult checks for network/API errors and returns the "data" field.
|
|
//
|
|
// Deprecated: use RuntimeContext.CallAPITyped (or ClassifyAPIResponse for
|
|
// self-driven requests) for typed error envelopes.
|
|
func HandleApiResult(result interface{}, err error, action string) (map[string]interface{}, error) {
|
|
if err != nil {
|
|
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
|
|
}
|
|
resultMap, _ := result.(map[string]interface{})
|
|
code, _ := util.ToFloat64(resultMap["code"])
|
|
if code != 0 {
|
|
msg, _ := resultMap["msg"].(string)
|
|
larkCode := int(code)
|
|
fullMsg := fmt.Sprintf("%s: [%d] %s", action, larkCode, msg)
|
|
return nil, output.ErrAPI(larkCode, fullMsg, resultMap["error"])
|
|
}
|
|
data, _ := resultMap["data"].(map[string]interface{})
|
|
return data, nil
|
|
}
|
|
|
|
// TruncateStr truncates s to at most n runes.
|
|
func TruncateStr(s string, n int) string {
|
|
r := []rune(s)
|
|
if len(r) <= n {
|
|
return s
|
|
}
|
|
return string(r[:n])
|
|
}
|