mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
19 Commits
codex/opti
...
feat-undo-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f381aa439 | ||
|
|
251635ec1e | ||
|
|
4936a983bf | ||
|
|
886cca6032 | ||
|
|
b64018a672 | ||
|
|
1996b67451 | ||
|
|
c1ee8613e4 | ||
|
|
41e6acba11 | ||
|
|
a042942f7e | ||
|
|
66c16758ec | ||
|
|
b42db647ff | ||
|
|
1cafb94a62 | ||
|
|
0b33daa136 | ||
|
|
5a61b97ac3 | ||
|
|
e01f2dfdd5 | ||
|
|
45f807459e | ||
|
|
8906e87fb1 | ||
|
|
0ff7f0407e | ||
|
|
6e067f2180 |
@@ -73,20 +73,20 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/|shortcuts/mail/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
|
||||
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper is scoped to migrated domains: the shared helpers
|
||||
# it bans are still used by other domains until their later migration phase.
|
||||
- path-except: (shortcuts/drive/|shortcuts/mail/)
|
||||
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
|
||||
# still used by other domains until their later migration phase.
|
||||
- path-except: (shortcuts/drive/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
@@ -115,17 +115,17 @@ linters:
|
||||
msg: >-
|
||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||
(see errs/types.go).
|
||||
# ── legacy shared error helpers banned on migrated domains ──
|
||||
# ── legacy shared error helpers banned on drive ──
|
||||
# These helpers internally produce legacy output.Err* shapes, so they
|
||||
# are invisible to the errs-typed-only ban above. Migrated domains use
|
||||
# typed errs.* builders or domain-local file-I/O helpers instead; this
|
||||
# prevents reintroduction while unmigrated domains continue to use the
|
||||
# shared helpers until their later migration phase.
|
||||
# are invisible to the errs-typed-only ban above. Drive has migrated its
|
||||
# calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
|
||||
# this prevents reintroduction. Other domains still use the shared
|
||||
# helpers (migrated globally in a later phase), so this is drive-scoped.
|
||||
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||
msg: >-
|
||||
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
|
||||
shapes. Use typed errs.NewXxxError builders or a domain-local
|
||||
file-I/O helper.
|
||||
shapes. Use the typed errs.NewXxxError builders or the drive-local
|
||||
driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
|
||||
# ── bare error wraps banned on fully-typed paths ──
|
||||
- pattern: (fmt\.Errorf|errors\.New)\b
|
||||
msg: >-
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -2,23 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.48] - 2026-06-04
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
|
||||
- **contact**: Add contact skill domain guidance (#1144)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **skills**: Use JSON skills list during update (#1251)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
|
||||
- **vc-agent**: Require explicit leave request (#1260)
|
||||
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
|
||||
|
||||
## [v1.0.47] - 2026-06-03
|
||||
|
||||
### Features
|
||||
@@ -1026,7 +1009,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
|
||||
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// universalFlags are accepted by every command (cobra auto-injects help; the
|
||||
// root injects version). They are never reported as unknown.
|
||||
var universalFlags = map[string]bool{"--help": true, "-h": true, "--version": true}
|
||||
|
||||
// catalog is the source-of-truth command catalog: command path -> accepted flag
|
||||
// tokens. A path is the command words WITHOUT the "lark-cli" root prefix, e.g.
|
||||
// "contact +search-user". The root command is the empty path "".
|
||||
type catalog struct {
|
||||
flagsByPath map[string]map[string]bool
|
||||
group map[string]bool // paths that are parent groups (have subcommands)
|
||||
sorted []string // cached sorted paths for suggestCommand; invalidated on addCommand
|
||||
}
|
||||
|
||||
func newCatalog() *catalog {
|
||||
return &catalog{
|
||||
flagsByPath: map[string]map[string]bool{},
|
||||
group: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// setGroup records whether path is a parent group (has subcommands). Leftover
|
||||
// words after a group node are unknown subcommands; after a leaf they are
|
||||
// positionals (e.g. "api GET /path").
|
||||
func (c *catalog) setGroup(path string, isGroup bool) {
|
||||
if isGroup {
|
||||
c.group[path] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (c *catalog) isGroup(path string) bool { return c.group[path] }
|
||||
|
||||
// addCommand registers a command path and the flags it accepts. Repeated calls
|
||||
// for the same path union the flag sets. flags are full tokens ("--query", "-q").
|
||||
func (c *catalog) addCommand(path string, flags []string) {
|
||||
set := c.flagsByPath[path]
|
||||
if set == nil {
|
||||
set = map[string]bool{}
|
||||
c.flagsByPath[path] = set
|
||||
}
|
||||
for _, f := range flags {
|
||||
set[f] = true
|
||||
}
|
||||
c.sorted = nil // invalidate cached suggestion list
|
||||
}
|
||||
|
||||
func (c *catalog) hasCommand(path string) bool {
|
||||
_, ok := c.flagsByPath[path]
|
||||
return ok
|
||||
}
|
||||
|
||||
// hasFlag reports whether flag is accepted by command path (universal flags
|
||||
// always pass).
|
||||
func (c *catalog) hasFlag(path, flag string) bool {
|
||||
if universalFlags[flag] {
|
||||
return true
|
||||
}
|
||||
set := c.flagsByPath[path]
|
||||
return set[flag]
|
||||
}
|
||||
|
||||
// longestPrefix returns the longest known command path that is a prefix of
|
||||
// words, plus how many words it consumed. This separates real subcommands from
|
||||
// trailing positionals (e.g. "api GET /path" resolves to "api"). When words is
|
||||
// empty it falls back to the root command. ok=false means not even the first
|
||||
// word names a command.
|
||||
func (c *catalog) longestPrefix(words []string) (path string, n int, ok bool) {
|
||||
if len(words) == 0 {
|
||||
if c.hasCommand("") {
|
||||
return "", 0, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
for i := len(words); i >= 1; i-- {
|
||||
cand := strings.Join(words[:i], " ")
|
||||
if c.hasCommand(cand) {
|
||||
return cand, i, true
|
||||
}
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
// paths returns all known command paths, sorted.
|
||||
func (c *catalog) paths() []string {
|
||||
out := make([]string, 0, len(c.flagsByPath))
|
||||
for p := range c.flagsByPath {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// suggestCommand returns the known command path closest to want (small edit
|
||||
// distance), for error hints. Returns "" when nothing is reasonably close.
|
||||
func (c *catalog) suggestCommand(want string) string {
|
||||
if c.sorted == nil {
|
||||
c.sorted = c.paths() // built once after the catalog is fully populated
|
||||
}
|
||||
return closest(want, c.sorted)
|
||||
}
|
||||
|
||||
// suggestFlag returns the flag of path closest to flag, for error hints.
|
||||
func (c *catalog) suggestFlag(path, flag string) string {
|
||||
set := c.flagsByPath[path]
|
||||
cands := make([]string, 0, len(set))
|
||||
for f := range set {
|
||||
cands = append(cands, f)
|
||||
}
|
||||
sort.Strings(cands)
|
||||
return closest(flag, cands)
|
||||
}
|
||||
|
||||
// closest returns the candidate with the smallest Levenshtein distance to want,
|
||||
// but only if that distance is within a tolerance scaled to want's length
|
||||
// (avoids absurd suggestions).
|
||||
func closest(want string, cands []string) string {
|
||||
best := ""
|
||||
bestD := 1 << 30
|
||||
for _, cand := range cands {
|
||||
d := levenshtein(want, cand)
|
||||
if d < bestD {
|
||||
bestD, best = d, cand
|
||||
}
|
||||
}
|
||||
tol := len(want)/2 + 1
|
||||
if bestD > tol {
|
||||
return ""
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func levenshtein(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
prev := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
cur := make([]int, len(rb)+1)
|
||||
cur[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev = cur
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd_test
|
||||
|
||||
import "strings"
|
||||
|
||||
// Finding kinds.
|
||||
const (
|
||||
unknownCommand = "unknown_command"
|
||||
unknownFlag = "unknown_flag"
|
||||
)
|
||||
|
||||
// finding is a single mismatch between an example command reference and the
|
||||
// catalog.
|
||||
type finding struct {
|
||||
line int
|
||||
raw string
|
||||
kind string // unknownCommand | unknownFlag
|
||||
path string // resolved command path (unknownFlag) or attempted path (unknownCommand)
|
||||
flag string // offending flag (unknownFlag only)
|
||||
suggest string // nearest known command/flag, "" if none close
|
||||
}
|
||||
|
||||
// checkRefs validates refs against cat and returns all mismatches in order.
|
||||
func checkRefs(cat *catalog, refs []ref) []finding {
|
||||
var out []finding
|
||||
for _, r := range refs {
|
||||
path, n, ok := cat.longestPrefix(r.words)
|
||||
if !ok {
|
||||
attempted := strings.Join(r.words, " ")
|
||||
out = append(out, finding{
|
||||
line: r.line, raw: r.raw, kind: unknownCommand,
|
||||
path: attempted, suggest: cat.suggestCommand(attempted),
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Leftover words after a group node are an unknown subcommand (e.g. a
|
||||
// mistyped method like "batch_modify_message"). After a leaf they are
|
||||
// positionals (e.g. "api GET /path"), so only groups trigger this.
|
||||
if n < len(r.words) && cat.isGroup(path) {
|
||||
attempted := strings.Join(r.words, " ")
|
||||
out = append(out, finding{
|
||||
line: r.line, raw: r.raw, kind: unknownCommand,
|
||||
path: attempted, suggest: cat.suggestCommand(attempted),
|
||||
})
|
||||
continue
|
||||
}
|
||||
for _, f := range r.flags {
|
||||
if cat.hasFlag(path, f) {
|
||||
continue
|
||||
}
|
||||
out = append(out, finding{
|
||||
line: r.line, raw: r.raw, kind: unknownFlag,
|
||||
path: path, flag: f, suggest: cat.suggestFlag(path, f),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ref is one lark-cli command reference extracted from a shortcut example.
|
||||
type ref struct {
|
||||
line int // 1-based line number (the line where the command starts)
|
||||
raw string // reconstructed command text, for error display
|
||||
words []string // command words before the first flag (subcommand candidates)
|
||||
flags []string // flag tokens used, e.g. "--query", "-q"
|
||||
}
|
||||
|
||||
const cliToken = "lark-cli"
|
||||
|
||||
// subcommandStart guards against false positives from prose: a real command's
|
||||
// first word is ASCII (a service name or a +shortcut). A token starting with
|
||||
// CJK / punctuation is treated as narration, not a command.
|
||||
var subcommandStart = regexp.MustCompile(`^[A-Za-z+]`)
|
||||
|
||||
// shellStops are standalone tokens that terminate a command (pipes, redirects,
|
||||
// separators). Separators glued to a token (`get;`, `foo|`) are handled inline.
|
||||
var shellStops = map[string]bool{
|
||||
"|": true, "||": true, "&&": true, "&": true, ";": true,
|
||||
">": true, ">>": true, "<": true, "2>": true, "2>&1": true,
|
||||
}
|
||||
|
||||
// wordTrailPunct is sentence / CJK punctuation that can cling to a command word
|
||||
// in prose ("auth login." / "auth login,"); stripped so the word still resolves
|
||||
// instead of being dropped as an unknown command or non-ASCII narration.
|
||||
const wordTrailPunct = `.,;:!?"')]},。、;:!?)】」』`
|
||||
|
||||
// parseRefs extracts every lark-cli command reference from text (a shortcut's
|
||||
// Tips line, which may embed an "Example: lark-cli ..." command). It is
|
||||
// deliberately format-agnostic: it keys on the "lark-cli" token whether it sits
|
||||
// in a ```bash fence, an inline `code` span, or bare prose. Backslash
|
||||
// line-continuations are joined first so a multi-line invocation is parsed as
|
||||
// one command; inline-code backticks and trailing # comments terminate it.
|
||||
func parseRefs(content string) []ref {
|
||||
var refs []ref
|
||||
lines := strings.Split(content, "\n")
|
||||
for i := 0; i < len(lines); i++ {
|
||||
lineNo := i + 1
|
||||
logical := lines[i]
|
||||
// Shell line continuation: a trailing backslash joins the next physical
|
||||
// line. Without this, flags on the continuation lines of a multi-line
|
||||
// `lark-cli ... \` example are never seen by the checker.
|
||||
for endsWithBackslash(logical) && i+1 < len(lines) {
|
||||
logical = strings.TrimRight(logical, " \t")
|
||||
logical = logical[:len(logical)-1] // drop the trailing backslash
|
||||
i++
|
||||
logical += " " + lines[i]
|
||||
}
|
||||
refs = append(refs, parseLine(logical, lineNo)...)
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func endsWithBackslash(s string) bool {
|
||||
return strings.HasSuffix(strings.TrimRight(s, " \t"), `\`)
|
||||
}
|
||||
|
||||
func parseLine(line string, lineNo int) []ref {
|
||||
var refs []ref
|
||||
rest := line
|
||||
for {
|
||||
idx := strings.Index(rest, cliToken)
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
after := rest[idx+len(cliToken):]
|
||||
beforeOK := idx == 0 || isBoundary(rest[idx-1])
|
||||
afterOK := after == "" || isBoundary(after[0])
|
||||
if beforeOK && afterOK {
|
||||
if words, flags, raw, ok := parseCmd(after); ok {
|
||||
refs = append(refs, ref{line: lineNo, raw: cliToken + raw, words: words, flags: flags})
|
||||
}
|
||||
}
|
||||
rest = after
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// parseCmd tokenizes the text following "lark-cli" into leading command words
|
||||
// (the subcommand path, up to the first flag) and flag tokens. It stops at a
|
||||
// shell separator (standalone or glued), an inline-code backtick, a comment, or
|
||||
// a placeholder/prose word. ok=false filters out non-commands.
|
||||
func parseCmd(after string) (words, flags []string, raw string, ok bool) {
|
||||
// An inline code span ends at the next backtick; a command never spans one.
|
||||
if i := strings.IndexByte(after, '`'); i >= 0 {
|
||||
after = after[:i]
|
||||
}
|
||||
// Drop $(...) command substitutions so flags belonging to the inner command
|
||||
// (e.g. `--data "$(jq -n --arg x ...)"`) are not mistaken for lark-cli flags.
|
||||
after = stripCmdSubst(after)
|
||||
|
||||
var kept []string
|
||||
inFlags := false
|
||||
for _, orig := range strings.Fields(after) {
|
||||
tok := orig
|
||||
if shellStops[tok] || strings.HasPrefix(tok, "#") {
|
||||
break
|
||||
}
|
||||
// A shell separator glued to a token ends the command mid-token
|
||||
// ("get;", "foo|next"): keep the part before it, handle it, then stop.
|
||||
stop := false
|
||||
if i := strings.IndexAny(tok, ";|"); i >= 0 {
|
||||
tok, stop = tok[:i], true
|
||||
}
|
||||
switch {
|
||||
case tok == "" || tok == "-":
|
||||
// empty (after a glued separator) or a bare stdin marker — skip
|
||||
case strings.HasPrefix(tok, "-"):
|
||||
if f := normalizeFlag(tok); f != "" {
|
||||
inFlags = true
|
||||
flags = append(flags, f)
|
||||
kept = append(kept, tok)
|
||||
}
|
||||
case inFlags:
|
||||
// positional / flag value after the first flag — not a command word
|
||||
kept = append(kept, tok)
|
||||
default:
|
||||
// Command-path word. ASCII placeholder markers (<x>, [x], {x|y},
|
||||
// +<verb>, ...) end the command — checked on the RAW token so the
|
||||
// trailing-punct stripping below cannot erase a "..." ellipsis
|
||||
// ("base +..." must stay a placeholder, not become "+").
|
||||
if strings.ContainsAny(tok, "<>[]{}|") || strings.Contains(tok, "...") {
|
||||
stop = true
|
||||
break
|
||||
}
|
||||
// Strip trailing sentence/CJK punctuation so "login." / "login,"
|
||||
// resolve to "login"; non-ASCII narration ends the command.
|
||||
w := strings.TrimRight(tok, wordTrailPunct)
|
||||
if w == "" || hasNonASCII(w) {
|
||||
stop = true
|
||||
break
|
||||
}
|
||||
words = append(words, w)
|
||||
kept = append(kept, tok)
|
||||
}
|
||||
if stop {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
raw = " " + strings.Join(kept, " ")
|
||||
}
|
||||
// Keep root-only refs ("lark-cli --help") and refs whose first word looks
|
||||
// like a subcommand; drop prose ("lark-cli 就能搞定 ...").
|
||||
if len(words) == 0 {
|
||||
return words, flags, raw, len(flags) > 0
|
||||
}
|
||||
if !subcommandStart.MatchString(words[0]) {
|
||||
return nil, nil, "", false
|
||||
}
|
||||
return words, flags, raw, true
|
||||
}
|
||||
|
||||
// stripCmdSubst removes $(...) command substitutions (including nested ones)
|
||||
// from s, leaving the surrounding text intact. Backtick substitutions are
|
||||
// already handled upstream (a command never spans a backtick).
|
||||
func stripCmdSubst(s string) string {
|
||||
var b strings.Builder
|
||||
depth := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if depth == 0 && i+1 < len(s) && s[i] == '$' && s[i+1] == '(' {
|
||||
depth = 1
|
||||
i++ // skip '('
|
||||
continue
|
||||
}
|
||||
if depth > 0 {
|
||||
switch s[i] {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
}
|
||||
continue
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// isPlaceholderOrProse reports whether a command word is a doc placeholder
|
||||
// (<resource>, [flags], {a|b}, +<verb>, ...) or narration (CJK / other
|
||||
// non-ASCII), rather than a literal command token.
|
||||
func isPlaceholderOrProse(w string) bool {
|
||||
if hasNonASCII(w) {
|
||||
return true
|
||||
}
|
||||
return strings.ContainsAny(w, "<>[]{}|") || strings.Contains(w, "...")
|
||||
}
|
||||
|
||||
func hasNonASCII(s string) bool {
|
||||
return strings.IndexFunc(s, func(r rune) bool { return r > 127 }) >= 0
|
||||
}
|
||||
|
||||
// flagShape matches the leading flag token, stripping any trailing junk such as
|
||||
// a "=value" suffix or punctuation that bled in from the surrounding markdown
|
||||
// ("--help\"", "--help;", "--params={}"). The underscore is allowed because
|
||||
// real flags use it ("--input_format", "--output_as"). Returns "" for non-flags.
|
||||
var flagShape = regexp.MustCompile(`^--?[A-Za-z][A-Za-z0-9_-]*`)
|
||||
|
||||
// normalizeFlag extracts the canonical flag token from tok, or "" if tok is not
|
||||
// a real flag (e.g. a shell-string fragment like "-草稿'").
|
||||
func normalizeFlag(tok string) string {
|
||||
return flagShape.FindString(tok)
|
||||
}
|
||||
|
||||
func isBoundary(b byte) bool {
|
||||
switch b {
|
||||
case ' ', '\t', '`', '(', ')', '\'', '"', '*':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// This file and its cmdexample_*_test.go siblings implement a test-only check:
|
||||
// the example commands embedded in shortcut definitions (the "Example: lark-cli
|
||||
// ..." lines in each shortcut's Tips, shown in --help) must match the real
|
||||
// command tree. It lives entirely in _test.go files (package cmd_test) so it
|
||||
// ships in no binary and is not importable by product code; the truth source is
|
||||
// cmd.Build, the same tree the binary uses, so the check cannot drift.
|
||||
//
|
||||
// It runs in the standard unit-test CI job (go test ./cmd/...). A mismatch — an
|
||||
// example using a renamed command or an unaccepted flag — fails that job.
|
||||
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// TestShortcutExampleCommands checks the example commands embedded in every
|
||||
// shortcut's Tips against the live command tree. A shortcut that defines no
|
||||
// example is simply skipped.
|
||||
//
|
||||
// Because the examples and the command definitions live in the same Go code,
|
||||
// this is a self-consistency check: any mismatch (an example using a renamed
|
||||
// command or a flag the command doesn't accept) is a bug to fix at the source.
|
||||
// It runs over all shortcuts — no baseline, no diff — since a wrong example is
|
||||
// always a defect, never acceptable "pre-existing drift".
|
||||
func TestShortcutExampleCommands(t *testing.T) {
|
||||
// Reproducibility: use the embedded API metadata (not a developer's stale
|
||||
// ~/.lark-cli remote cache, which can miss commands) and an empty config
|
||||
// dir so local strict mode / plugins / policy cannot reshape the tree.
|
||||
// t.Setenv auto-restores after the test, so other cmd tests are unaffected.
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
cat := buildCmdExampleCatalog()
|
||||
|
||||
type located struct {
|
||||
shortcut string
|
||||
f finding
|
||||
}
|
||||
var findings []located
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
var refs []ref
|
||||
for _, tip := range sc.Tips {
|
||||
refs = append(refs, parseRefs(tip)...)
|
||||
}
|
||||
label := strings.TrimSpace(sc.Service + " " + sc.Command)
|
||||
for _, f := range checkRefs(cat, refs) {
|
||||
findings = append(findings, located{shortcut: label, f: f})
|
||||
}
|
||||
}
|
||||
|
||||
if len(findings) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Slice(findings, func(i, j int) bool { return findings[i].shortcut < findings[j].shortcut })
|
||||
for _, lf := range findings {
|
||||
hint := ""
|
||||
if lf.f.suggest != "" {
|
||||
hint = " (did you mean " + lf.f.suggest + "?)"
|
||||
}
|
||||
if lf.f.kind == unknownFlag {
|
||||
t.Errorf("shortcut %q example uses unknown flag %s on %q%s\n %s",
|
||||
lf.shortcut, lf.f.flag, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
|
||||
} else {
|
||||
t.Errorf("shortcut %q example uses unknown command %q%s\n %s",
|
||||
lf.shortcut, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
|
||||
}
|
||||
}
|
||||
t.Fatalf("%d shortcut example command(s) don't match the real CLI — "+
|
||||
"fix the Example in the shortcut definition.", len(findings))
|
||||
}
|
||||
|
||||
// buildCmdExampleCatalog walks the live cobra command tree and records every
|
||||
// command path (minus the "lark-cli" root prefix) with its accepted flags and
|
||||
// whether it is a parent group. This is the same Build() the binary uses, so
|
||||
// the catalog can never drift from the real commands.
|
||||
func buildCmdExampleCatalog() *catalog {
|
||||
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
|
||||
cat := newCatalog()
|
||||
var walk func(c *cobra.Command)
|
||||
walk = func(c *cobra.Command) {
|
||||
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
|
||||
var flags []string
|
||||
add := func(fl *pflag.Flag) {
|
||||
flags = append(flags, "--"+fl.Name)
|
||||
if fl.Shorthand != "" {
|
||||
flags = append(flags, "-"+fl.Shorthand)
|
||||
}
|
||||
}
|
||||
c.Flags().VisitAll(add)
|
||||
c.InheritedFlags().VisitAll(add)
|
||||
c.PersistentFlags().VisitAll(add) // root's own persistent flags (e.g. --profile)
|
||||
cat.addCommand(path, flags)
|
||||
cat.setGroup(path, c.HasSubCommands())
|
||||
for _, sub := range c.Commands() {
|
||||
walk(sub)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return cat
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testCatalog() *catalog {
|
||||
c := newCatalog()
|
||||
c.addCommand("", []string{"--profile"}) // root
|
||||
c.setGroup("", true)
|
||||
c.addCommand("contact", []string{"--profile"})
|
||||
c.setGroup("contact", true)
|
||||
c.addCommand("contact +search-user", []string{"--query", "--as", "--format", "-q"})
|
||||
c.addCommand("api", []string{"--params", "--data", "--as"}) // leaf (no subcommands)
|
||||
c.addCommand("mail", nil)
|
||||
c.setGroup("mail", true)
|
||||
c.addCommand("mail user_mailbox.messages", []string{"--profile"})
|
||||
c.setGroup("mail user_mailbox.messages", true)
|
||||
c.addCommand("mail user_mailbox.messages batch_modify", []string{"--params", "--data"})
|
||||
return c
|
||||
}
|
||||
|
||||
func TestCmdExampleCatalogHasCommandAndFlag(t *testing.T) {
|
||||
c := testCatalog()
|
||||
if !c.hasCommand("contact +search-user") {
|
||||
t.Fatal("expected contact +search-user to exist")
|
||||
}
|
||||
if c.hasCommand("contact +nope") {
|
||||
t.Fatal("did not expect contact +nope")
|
||||
}
|
||||
if !c.hasFlag("contact +search-user", "--query") {
|
||||
t.Fatal("--query should be valid")
|
||||
}
|
||||
if c.hasFlag("contact +search-user", "--nope") {
|
||||
t.Fatal("--nope should be invalid")
|
||||
}
|
||||
// universal flags pass on any command
|
||||
for _, f := range []string{"--help", "-h", "--version"} {
|
||||
if !c.hasFlag("contact +search-user", f) {
|
||||
t.Fatalf("universal flag %s should pass", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExampleLongestPrefix(t *testing.T) {
|
||||
c := testCatalog()
|
||||
tests := []struct {
|
||||
words []string
|
||||
want string
|
||||
wantN int
|
||||
wantOK bool
|
||||
}{
|
||||
{[]string{"contact", "+search-user"}, "contact +search-user", 2, true},
|
||||
{[]string{"api", "GET", "/open-apis/x"}, "api", 1, true}, // trailing positionals
|
||||
{[]string{"nope"}, "", 0, false},
|
||||
{nil, "", 0, true}, // empty -> root
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, n, ok := c.longestPrefix(tt.words)
|
||||
if got != tt.want || n != tt.wantN || ok != tt.wantOK {
|
||||
t.Errorf("longestPrefix(%v) = (%q,%d,%v), want (%q,%d,%v)",
|
||||
tt.words, got, n, ok, tt.want, tt.wantN, tt.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refWordsOf(refs []ref) [][]string {
|
||||
var out [][]string
|
||||
for _, r := range refs {
|
||||
out = append(out, r.words)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCmdExampleParseRefsExtractsCommands(t *testing.T) {
|
||||
content := strings.Join([]string{
|
||||
"运行 `lark-cli contact +search-user --query 张三` 搜索", // inline code
|
||||
"```bash",
|
||||
"lark-cli api GET /open-apis/x --params '{}'", // bash block
|
||||
"```",
|
||||
"用 lark-cli mail user_mailbox.messages batch_modify 即可", // bare prose command
|
||||
"npx foo | lark-cli api GET /y", // after a pipe
|
||||
}, "\n")
|
||||
refs := parseRefs(content)
|
||||
if len(refs) != 4 {
|
||||
t.Fatalf("expected 4 refs, got %d: %v", len(refs), refWordsOf(refs))
|
||||
}
|
||||
if got := refs[0]; strings.Join(got.words, " ") != "contact +search-user" ||
|
||||
len(got.flags) != 1 || got.flags[0] != "--query" {
|
||||
t.Errorf("ref0 = %+v", got)
|
||||
}
|
||||
if got := refs[1]; strings.Join(got.words, " ") != "api GET /open-apis/x" {
|
||||
t.Errorf("ref1 words = %v", got.words)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExampleParseRefsFiltersPlaceholdersAndProse(t *testing.T) {
|
||||
// A line whose first word is prose yields no command at all.
|
||||
if refs := parseRefs("lark-cli 就能搞定这件事"); len(refs) != 0 {
|
||||
t.Errorf("prose-first line should yield 0 refs, got %v", refWordsOf(refs))
|
||||
}
|
||||
// Syntax templates / trailing prose may leave a real leading word ("mail"),
|
||||
// but no placeholder or CJK token may leak into the command words — that is
|
||||
// what prevents false positives like an "<resource>" unknown-command report.
|
||||
for _, line := range []string{
|
||||
"lark-cli mail <resource> <method> [flags]",
|
||||
"lark-cli apps +<verb> [flags]",
|
||||
"lark-cli base +...",
|
||||
"lark-cli mail 写信场景下的格式说明",
|
||||
} {
|
||||
for _, r := range parseRefs(line) {
|
||||
for _, w := range r.words {
|
||||
if isPlaceholderOrProse(w) {
|
||||
t.Errorf("%q: placeholder/prose token %q leaked into words %v", line, w, r.words)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExampleParseRefsStripsTrailingJunk(t *testing.T) {
|
||||
// frontmatter-style quoted value: the trailing quote must not bleed into the flag
|
||||
refs := parseRefs(`cliHelp: "lark-cli contact --help"`)
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("expected 1 ref, got %d", len(refs))
|
||||
}
|
||||
if len(refs[0].flags) != 1 || refs[0].flags[0] != "--help" {
|
||||
t.Errorf("expected flag --help, got %v", refs[0].flags)
|
||||
}
|
||||
// bare "-" (stdin marker) and "=value" suffix
|
||||
refs = parseRefs("lark-cli api GET /x --params={} --data -")
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("expected 1 ref, got %d", len(refs))
|
||||
}
|
||||
flags := strings.Join(refs[0].flags, " ")
|
||||
if flags != "--params --data" {
|
||||
t.Errorf("expected '--params --data', got %q", flags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExampleCheck(t *testing.T) {
|
||||
c := testCatalog()
|
||||
tests := []struct {
|
||||
name string
|
||||
r ref
|
||||
wantKind string // "" = no finding
|
||||
wantPath string
|
||||
}{
|
||||
{"valid shortcut", ref{words: []string{"contact", "+search-user"}, flags: []string{"--query"}}, "", ""},
|
||||
{"valid leaf positional", ref{words: []string{"api", "GET", "/x"}}, "", ""},
|
||||
{"unknown top command", ref{words: []string{"nope"}}, unknownCommand, "nope"},
|
||||
{"group leftover = unknown subcommand",
|
||||
ref{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}},
|
||||
unknownCommand, "mail user_mailbox.messages batch_modify_message"},
|
||||
{"unknown flag", ref{words: []string{"contact", "+search-user"}, flags: []string{"--nope"}}, unknownFlag, "contact +search-user"},
|
||||
{"universal flag ok", ref{words: []string{"contact", "+search-user"}, flags: []string{"--help"}}, "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fs := checkRefs(c, []ref{tt.r})
|
||||
if tt.wantKind == "" {
|
||||
if len(fs) != 0 {
|
||||
t.Fatalf("expected no finding, got %+v", fs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(fs) != 1 {
|
||||
t.Fatalf("expected 1 finding, got %d: %+v", len(fs), fs)
|
||||
}
|
||||
if fs[0].kind != tt.wantKind || fs[0].path != tt.wantPath {
|
||||
t.Errorf("got kind=%s path=%q, want kind=%s path=%q", fs[0].kind, fs[0].path, tt.wantKind, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExampleCheckSuggestsNearest(t *testing.T) {
|
||||
c := testCatalog()
|
||||
fs := checkRefs(c, []ref{{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}}})
|
||||
if len(fs) != 1 || fs[0].suggest != "mail user_mailbox.messages batch_modify" {
|
||||
t.Fatalf("expected suggestion 'mail user_mailbox.messages batch_modify', got %+v", fs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdExampleParseRefsRobustness covers the parser edge cases hardened after
|
||||
// review: backslash continuation, underscore flags, $(...) substitution, glued
|
||||
// separators, trailing punctuation, and the "..." placeholder.
|
||||
func TestCmdExampleParseRefsRobustness(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, content, wantWords, wantFlags string
|
||||
wantRefs int
|
||||
}{
|
||||
{"backslash continuation joins flags",
|
||||
"lark-cli contact +search-user \\\n --query foo \\\n --as user",
|
||||
"contact +search-user", "--query --as", 1},
|
||||
{"underscore flag not truncated",
|
||||
"lark-cli whiteboard +update --input_format mermaid",
|
||||
"whiteboard +update", "--input_format", 1},
|
||||
{"command-substitution flags ignored",
|
||||
`lark-cli slides x create --data "$(jq -n --arg c '{}')" --as user`,
|
||||
"slides x create", "--data --as", 1},
|
||||
{"glued separator truncates",
|
||||
"lark-cli auth login; echo done",
|
||||
"auth login", "", 1},
|
||||
{"trailing CJK punctuation stripped",
|
||||
"用 lark-cli auth login。",
|
||||
"auth login", "", 1},
|
||||
{"ellipsis placeholder stays placeholder",
|
||||
"lark-cli base +...",
|
||||
"base", "", 1},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
refs := parseRefs(tt.content)
|
||||
if len(refs) != tt.wantRefs {
|
||||
t.Fatalf("refs=%d want %d: %v", len(refs), tt.wantRefs, refWordsOf(refs))
|
||||
}
|
||||
if tt.wantRefs == 0 {
|
||||
return
|
||||
}
|
||||
if got := strings.Join(refs[0].words, " "); got != tt.wantWords {
|
||||
t.Errorf("words=%q want %q", got, tt.wantWords)
|
||||
}
|
||||
if got := strings.Join(refs[0].flags, " "); got != tt.wantFlags {
|
||||
t.Errorf("flags=%q want %q", got, tt.wantFlags)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
switch strings.Join(args, " ") {
|
||||
case "-y skills add https://open.feishu.cn --list":
|
||||
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
|
||||
case "-y skills ls -g --json":
|
||||
r.Stdout.WriteString(`[{"name":"lark-calendar","path":"/tmp/lark-calendar","scope":"global","agents":["Codex"]},{"name":"custom-skill","path":"/tmp/custom-skill","scope":"global","agents":["Codex"]}]`)
|
||||
case "-y skills ls -g":
|
||||
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
|
||||
default:
|
||||
|
||||
@@ -244,8 +244,6 @@ func APIHint(subtype errs.Subtype) string {
|
||||
return "operate on source and target within the same tenant and region/unit"
|
||||
case errs.SubtypeCrossBrand:
|
||||
return "operate on source and target within the same brand environment"
|
||||
case errs.SubtypeQuotaExceeded:
|
||||
return "reduce the request volume or free quota, then retry after the relevant quota resets"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// mailCodeMeta holds mail-service Lark code -> CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
var mailCodeMeta = map[int]CodeMeta{
|
||||
1234013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // mailbox not found or not active
|
||||
1236007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily send count exceeded
|
||||
1236008: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily external recipient count exceeded
|
||||
1236009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant daily external recipient count exceeded
|
||||
1236010: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // mail quota limit
|
||||
1236013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant storage limit exceeded
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(mailCodeMeta, "mail") }
|
||||
@@ -165,10 +165,6 @@ func (u *Updater) ListGlobalSkills() *NpmResult {
|
||||
return u.runSkillsListGlobal()
|
||||
}
|
||||
|
||||
func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "ls", "-g", "--json")
|
||||
}
|
||||
|
||||
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
|
||||
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
|
||||
if r.Err != nil {
|
||||
|
||||
@@ -188,13 +188,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
},
|
||||
want: "-y skills ls -g",
|
||||
},
|
||||
{
|
||||
name: "list global json",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.ListGlobalSkillsJSON()
|
||||
},
|
||||
want: "-y skills ls -g --json",
|
||||
},
|
||||
{
|
||||
name: "install skill primary",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -58,28 +57,6 @@ func ParseSkillsList(text string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseGlobalSkillsJSON(text string) []string {
|
||||
type globalSkill struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
var skills []globalSkill
|
||||
if err := json.Unmarshal([]byte(text), &skills); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, skill := range skills {
|
||||
candidate := strings.TrimSpace(skill.Name)
|
||||
if candidate == "" || !skillNamePattern.MatchString(candidate) {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
}
|
||||
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
|
||||
func parseGlobalSkillsList(lines []string) []string {
|
||||
seen := map[string]bool{}
|
||||
@@ -100,11 +77,8 @@ func parseGlobalSkillsList(lines []string) []string {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "Agents:") {
|
||||
continue
|
||||
}
|
||||
|
||||
if isGlobalSkillsSectionHeader(trimmed) {
|
||||
// Skip indented lines (Agents: ...)
|
||||
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -117,24 +91,21 @@ func parseGlobalSkillsList(lines []string) []string {
|
||||
candidate := parts[0]
|
||||
|
||||
// Validate and add
|
||||
if candidate == "" || !skillNamePattern.MatchString(candidate) {
|
||||
if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(candidate, ":") {
|
||||
continue
|
||||
}
|
||||
if !skillNamePattern.MatchString(candidate) {
|
||||
continue
|
||||
}
|
||||
if at := strings.Index(candidate, "@"); at > 0 {
|
||||
candidate = candidate[:at]
|
||||
}
|
||||
seen[candidate] = true
|
||||
}
|
||||
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func isGlobalSkillsSectionHeader(line string) bool {
|
||||
switch line {
|
||||
case "General", "Project", "Local":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// parseOfficialSkillsList parses the output of "npx -y skills add ... --list"
|
||||
func parseOfficialSkillsList(lines []string) []string {
|
||||
seen := map[string]bool{}
|
||||
@@ -224,7 +195,6 @@ func PlanSync(input SyncInput) SyncPlan {
|
||||
|
||||
type SkillsRunner interface {
|
||||
ListOfficialSkills() *selfupdate.NpmResult
|
||||
ListGlobalSkillsJSON() *selfupdate.NpmResult
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
InstallSkill(nameList []string) *selfupdate.NpmResult
|
||||
InstallAllSkills() *selfupdate.NpmResult
|
||||
@@ -269,9 +239,10 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
}
|
||||
|
||||
// --- Step 2: List local (installed) skills ---
|
||||
local, ok := listLocalSkills(opts.Runner)
|
||||
if !ok {
|
||||
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
|
||||
local := []string{}
|
||||
localResult := opts.Runner.ListGlobalSkills()
|
||||
if localResult != nil && localResult.Err == nil {
|
||||
local = ParseSkillsList(localResult.Stdout.String())
|
||||
}
|
||||
|
||||
// --- Step 3: Read previous state ---
|
||||
@@ -327,24 +298,6 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
|
||||
jsonResult := runner.ListGlobalSkillsJSON()
|
||||
if jsonResult != nil && jsonResult.Err == nil {
|
||||
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
|
||||
return local, true
|
||||
}
|
||||
}
|
||||
|
||||
textResult := runner.ListGlobalSkills()
|
||||
if textResult != nil && textResult.Err == nil {
|
||||
if local := ParseSkillsList(textResult.Stdout.String()); len(local) > 0 {
|
||||
return local, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// fallbackFullInstall performs a full skills install (npx -y skills add <source> -g -y)
|
||||
// when incremental sync is not possible. On success it writes a state file so that
|
||||
// subsequent syncs can use incremental mode. When official is non-nil the state
|
||||
|
||||
@@ -67,49 +67,6 @@ func TestParseGlobalSkillsListWithANSI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsListWithIndentedGroupedRows(t *testing.T) {
|
||||
input := `Global Skills
|
||||
|
||||
General
|
||||
lark-apps ~/.agents/skills/lark-apps
|
||||
lark-base ~/.agents/skills/lark-base
|
||||
`
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"lark-apps", "lark-base"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() (indented Global Skills) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsJSON(t *testing.T) {
|
||||
input := `[
|
||||
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
|
||||
{"name":"lark-mail@1.2.3","path":"/Users/example/.agents/skills/lark-mail","scope":"global","agents":["Codex"]},
|
||||
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
|
||||
{"name":" lark-base ","path":"/Users/example/.agents/skills/lark-base","scope":"global","agents":["Codex"]},
|
||||
{"name":""},
|
||||
{"name":" "},
|
||||
{"name":"bad skill"}
|
||||
]`
|
||||
got := ParseGlobalSkillsJSON(input)
|
||||
want := []string{"lark-base", "lark-calendar", "lark-mail@1.2.3"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseGlobalSkillsJSON() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`not json`,
|
||||
`{"name":"lark-calendar"}`,
|
||||
`[]`,
|
||||
} {
|
||||
if got := ParseGlobalSkillsJSON(input); len(got) != 0 {
|
||||
t.Fatalf("ParseGlobalSkillsJSON(%q) = %#v, want empty", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
|
||||
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
|
||||
got := PlanSync(SyncInput{
|
||||
@@ -156,18 +113,14 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeSkillsRunner struct {
|
||||
officialOut string
|
||||
globalJSONOut string
|
||||
globalOut string
|
||||
officialErr error
|
||||
globalJSONErr error
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
listedGlobalJSON int
|
||||
listedGlobalText int
|
||||
officialOut string
|
||||
globalOut string
|
||||
officialErr error
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
}
|
||||
|
||||
func officialSkillsOutput(names ...string) string {
|
||||
@@ -193,19 +146,6 @@ func globalSkillsOutput(names ...string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func globalSkillsJSONOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"name":%q,"path":"/Users/example/.agents/skills/%s","scope":"global","agents":["Codex"]}`, name, name)
|
||||
}
|
||||
b.WriteString("]")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.officialOut)
|
||||
@@ -213,16 +153,7 @@ func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListGlobalSkillsJSON() *selfupdate.NpmResult {
|
||||
f.listedGlobalJSON++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.globalJSONOut)
|
||||
r.Err = f.globalJSONErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
|
||||
f.listedGlobalText++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.globalOut)
|
||||
r.Err = f.globalErr
|
||||
@@ -255,9 +186,8 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
|
||||
}
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
@@ -269,12 +199,6 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"})
|
||||
if runner.listedGlobalJSON != 1 {
|
||||
t.Fatalf("listedGlobalJSON = %d, want 1", runner.listedGlobalJSON)
|
||||
}
|
||||
if runner.listedGlobalText != 0 {
|
||||
t.Fatalf("listedGlobalText = %d, want 0 when JSON list succeeds", runner.listedGlobalText)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
@@ -338,73 +262,47 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
|
||||
func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed"),
|
||||
globalOut: globalSkillsOutput("lark-calendar"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalErr: fmt.Errorf("global list failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
|
||||
}
|
||||
if result.Action != "synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
|
||||
}
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
if runner.listedGlobalJSON != 1 || runner.listedGlobalText != 1 {
|
||||
t.Fatalf("listed JSON/text = %d/%d, want 1/1", runner.listedGlobalJSON, runner.listedGlobalText)
|
||||
assertStrings(t, result.SkippedDeleted, []string{})
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: "Some unrecognized output format\n",
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
|
||||
}
|
||||
if result.Action != "synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
|
||||
}
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.SkippedDeleted, []string{})
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
|
||||
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
if strings.Contains(result.Detail, "/Users/example") || strings.Contains(result.Detail, "agents") {
|
||||
t.Fatalf("SyncSkills() detail leaks local command output: %q", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: `[]`,
|
||||
globalOut: "Some unrecognized output format\n",
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
if len(runner.installed) != 1 {
|
||||
t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +344,6 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
@@ -478,7 +375,6 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: fmt.Errorf("full install boom"),
|
||||
@@ -577,7 +473,6 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
@@ -602,7 +497,6 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
@@ -643,9 +537,8 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
||||
}
|
||||
|
||||
runner2 := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
}
|
||||
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
|
||||
if result2.Action != "synced" {
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
// common replacements or construct an errs.* typed error directly.
|
||||
var migratedCommonHelperPaths = []string{
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/mail/",
|
||||
}
|
||||
|
||||
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
// appending their path prefix here.
|
||||
var migratedEnvelopePaths = []string{
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/mail/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
|
||||
@@ -894,33 +894,27 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
|
||||
"ResolveOpenIDs",
|
||||
"HandleApiResult",
|
||||
}
|
||||
paths := []string{
|
||||
"shortcuts/drive/drive_search.go",
|
||||
"shortcuts/mail/mail_send.go",
|
||||
}
|
||||
for _, path := range paths {
|
||||
for _, helper := range helpers {
|
||||
t.Run(path+"_"+helper, func(t *testing.T) {
|
||||
src := `package migrated
|
||||
for _, helper := range helpers {
|
||||
t.Run(helper, func(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.` + helper + `()
|
||||
common.` + helper + `()
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall(path, src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for %s on %s, got %d: %+v", helper, path, len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "common."+helper) {
|
||||
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for %s, got %d: %+v", helper, len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "common."+helper) {
|
||||
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.48",
|
||||
"version": "1.0.47",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -58,7 +57,6 @@ type DriveMediaMultipartUploadConfig struct {
|
||||
Reader io.Reader
|
||||
}
|
||||
|
||||
// Deprecated: use UploadDriveMediaAllTyped for typed error envelopes.
|
||||
func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
|
||||
var fileReader io.Reader
|
||||
if cfg.Reader != nil {
|
||||
@@ -100,52 +98,6 @@ func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig)
|
||||
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadAllAction)
|
||||
}
|
||||
|
||||
// UploadDriveMediaAllTyped is the typed-error counterpart of
|
||||
// UploadDriveMediaAll: file-open failures surface as typed validation errors,
|
||||
// transport failures as typed network errors, and API failures are classified
|
||||
// via ClassifyAPIResponse so subtype / code / log_id survive on the error.
|
||||
func UploadDriveMediaAllTyped(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
|
||||
var fileReader io.Reader
|
||||
if cfg.Reader != nil {
|
||||
fileReader = cfg.Reader
|
||||
} else {
|
||||
f, err := runtime.FileIO().Open(cfg.FilePath)
|
||||
if err != nil {
|
||||
return "", WrapInputStatErrorTyped(err)
|
||||
}
|
||||
defer f.Close()
|
||||
fileReader = f
|
||||
}
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", cfg.FileName)
|
||||
fd.AddField("parent_type", cfg.ParentType)
|
||||
fd.AddField("size", fmt.Sprintf("%d", cfg.FileSize))
|
||||
if cfg.ParentNode != nil {
|
||||
fd.AddField("parent_node", *cfg.ParentNode)
|
||||
}
|
||||
if cfg.Extra != "" {
|
||||
fd.AddField("extra", cfg.Extra)
|
||||
}
|
||||
fd.AddFile("file", fileReader)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return "", prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadAllAction)
|
||||
}
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return "", prefixDriveMediaUploadProblem(err, driveMediaUploadAllAction)
|
||||
}
|
||||
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadAllAction)
|
||||
}
|
||||
|
||||
// Deprecated: use UploadDriveMediaMultipartTyped for typed error envelopes.
|
||||
func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
|
||||
// upload_prepare expects parent_node to be present even when the caller wants
|
||||
// the service default/root behavior, so multipart callers pass an explicit
|
||||
@@ -178,43 +130,6 @@ func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartU
|
||||
return finishDriveMediaMultipartUpload(runtime, session.UploadID, session.BlockNum)
|
||||
}
|
||||
|
||||
// UploadDriveMediaMultipartTyped is the typed-error counterpart of
|
||||
// UploadDriveMediaMultipart: prepare/finish failures come back typed from
|
||||
// CallAPITyped, malformed session plans surface as invalid-response internal
|
||||
// errors, and per-part transport/API failures are classified the same way as
|
||||
// UploadDriveMediaAllTyped.
|
||||
func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
|
||||
// upload_prepare expects parent_node to be present even when the caller wants
|
||||
// the service default/root behavior, so multipart callers pass an explicit
|
||||
// string instead of relying on field omission like upload_all does.
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": cfg.FileName,
|
||||
"parent_type": cfg.ParentType,
|
||||
"parent_node": cfg.ParentNode,
|
||||
"size": cfg.FileSize,
|
||||
}
|
||||
if cfg.Extra != "" {
|
||||
prepareBody["extra"] = cfg.Extra
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
session, err := parseDriveMediaMultipartUploadSessionTyped(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
|
||||
|
||||
if err = uploadDriveMediaMultipartPartsTyped(runtime, cfg, session); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
|
||||
}
|
||||
|
||||
func ParseDriveMediaMultipartUploadSession(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
|
||||
// The backend chooses both chunk size and chunk count. Validate them once so
|
||||
// the streaming loop can follow the returned plan without re-checking shape.
|
||||
@@ -365,122 +280,3 @@ func finishDriveMediaMultipartUpload(runtime *RuntimeContext, uploadID string, b
|
||||
}
|
||||
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadFinishAction)
|
||||
}
|
||||
|
||||
// prefixDriveMediaUploadProblem prepends the upload action to a typed error's
|
||||
// message so callers see which upload step failed. Non-typed errors are
|
||||
// returned unchanged.
|
||||
func prefixDriveMediaUploadProblem(err error, action string) error {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
p.Message = action + ": " + p.Message
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// parseDriveMediaMultipartUploadSessionTyped validates the upload_prepare
|
||||
// session plan like ParseDriveMediaMultipartUploadSession, but reports a
|
||||
// malformed plan as a typed invalid-response internal error.
|
||||
func parseDriveMediaMultipartUploadSessionTyped(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
|
||||
session := DriveMediaMultipartUploadSession{
|
||||
UploadID: GetString(data, "upload_id"),
|
||||
BlockSize: int64(GetFloat(data, "block_size")),
|
||||
BlockNum: int(GetFloat(data, "block_num")),
|
||||
}
|
||||
if session.UploadID == "" {
|
||||
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: no upload_id returned")
|
||||
}
|
||||
if session.BlockSize <= 0 {
|
||||
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
|
||||
}
|
||||
if session.BlockNum <= 0 {
|
||||
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_num returned")
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// extractDriveMediaUploadFileTokenTyped mirrors ExtractDriveMediaUploadFileToken
|
||||
// with a typed invalid-response internal error for a missing file_token.
|
||||
func extractDriveMediaUploadFileTokenTyped(data map[string]interface{}, action string) (string, error) {
|
||||
fileToken := GetString(data, "file_token")
|
||||
if fileToken == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: no file_token returned", action)
|
||||
}
|
||||
return fileToken, nil
|
||||
}
|
||||
|
||||
// uploadDriveMediaMultipartPartsTyped mirrors uploadDriveMediaMultipartParts
|
||||
// with typed errors for file-open, file-read, and per-part upload failures.
|
||||
func uploadDriveMediaMultipartPartsTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig, session DriveMediaMultipartUploadSession) error {
|
||||
var r io.Reader
|
||||
if cfg.Reader != nil {
|
||||
r = cfg.Reader
|
||||
} else {
|
||||
f, err := runtime.FileIO().Open(cfg.FilePath)
|
||||
if err != nil {
|
||||
return WrapInputStatErrorTyped(err)
|
||||
}
|
||||
defer f.Close()
|
||||
r = f
|
||||
}
|
||||
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
bufferSize := session.BlockSize
|
||||
if bufferSize <= 0 || bufferSize > maxInt {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
|
||||
}
|
||||
buffer := make([]byte, int(bufferSize))
|
||||
remaining := cfg.FileSize
|
||||
// Follow the server-declared block plan exactly; upload_finish expects the
|
||||
// same block count returned by upload_prepare.
|
||||
for seq := 0; seq < session.BlockNum; seq++ {
|
||||
chunkSize := session.BlockSize
|
||||
if remaining > 0 && chunkSize > remaining {
|
||||
chunkSize = remaining
|
||||
}
|
||||
|
||||
n, readErr := io.ReadFull(r, buffer[:int(chunkSize)])
|
||||
if readErr != nil {
|
||||
return WrapInputStatErrorTyped(readErr)
|
||||
}
|
||||
|
||||
if err := uploadDriveMediaMultipartPartTyped(runtime, session.UploadID, seq, buffer[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n)))
|
||||
remaining -= int64(n)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploadDriveMediaMultipartPartTyped(runtime *RuntimeContext, uploadID string, seq int, chunk []byte) error {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", uploadID)
|
||||
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
||||
fd.AddField("size", fmt.Sprintf("%d", len(chunk)))
|
||||
fd.AddFile("file", bytes.NewReader(chunk))
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadPartAction)
|
||||
}
|
||||
|
||||
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||
return prefixDriveMediaUploadProblem(err, driveMediaUploadPartAction)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func finishDriveMediaMultipartUploadTyped(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) {
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": uploadID,
|
||||
"block_num": blockNum,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadFinishAction)
|
||||
}
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestUploadDriveMediaAllTypedWithInMemoryContent(t *testing.T) {
|
||||
runtime, reg := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "file_typed_123"},
|
||||
},
|
||||
})
|
||||
|
||||
payload := []byte{0x89, 0x50, 0x4e, 0x47}
|
||||
fileToken, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
|
||||
Reader: bytes.NewReader(payload),
|
||||
FileName: "clipboard.png",
|
||||
FileSize: int64(len(payload)),
|
||||
ParentType: "docx_image",
|
||||
ParentNode: strPtr("blk_parent"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadDriveMediaAllTyped() error: %v", err)
|
||||
}
|
||||
if fileToken != "file_typed_123" {
|
||||
t.Fatalf("fileToken = %q, want %q", fileToken, "file_typed_123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadDriveMediaAllTypedClassifiesAPIFailure(t *testing.T) {
|
||||
runtime, reg := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "upload rejected",
|
||||
},
|
||||
})
|
||||
|
||||
payload := []byte{0x01}
|
||||
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
|
||||
Reader: bytes.NewReader(payload),
|
||||
FileName: "clipboard.png",
|
||||
FileSize: int64(len(payload)),
|
||||
ParentType: "docx_image",
|
||||
ParentNode: strPtr("blk_parent"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Fatalf("category = %s, want api", p.Category)
|
||||
}
|
||||
if p.Code != 999 {
|
||||
t.Fatalf("code = %d, want 999", p.Code)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, "upload media failed: ") || !strings.Contains(p.Message, "upload rejected") {
|
||||
t.Fatalf("message = %q, want action prefix and server msg", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadDriveMediaAllTypedFileOpenFailure(t *testing.T) {
|
||||
runtime, _ := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
|
||||
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
|
||||
FilePath: "missing.bin",
|
||||
FileName: "missing.bin",
|
||||
FileSize: 1,
|
||||
ParentType: "docx_image",
|
||||
ParentNode: strPtr("blk_parent"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected typed validation error, got %T (%v)", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadDriveMediaMultipartTypedBuildsPreparePartsAndFinish(t *testing.T) {
|
||||
runtime, reg := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
|
||||
size := MaxDriveMediaUploadSinglePartSize + 1
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_typed_1",
|
||||
"block_size": float64(4 * 1024 * 1024),
|
||||
"block_num": float64(6),
|
||||
},
|
||||
},
|
||||
})
|
||||
for i := 0; i < 6; i++ {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "file_typed_multi"},
|
||||
},
|
||||
})
|
||||
|
||||
payload := bytes.Repeat([]byte{0xCD}, int(size))
|
||||
fileToken, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
|
||||
Reader: bytes.NewReader(payload),
|
||||
FileName: "clipboard.png",
|
||||
FileSize: size,
|
||||
ParentType: "docx_image",
|
||||
ParentNode: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadDriveMediaMultipartTyped() error: %v", err)
|
||||
}
|
||||
if fileToken != "file_typed_multi" {
|
||||
t.Fatalf("fileToken = %q, want %q", fileToken, "file_typed_multi")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveMediaMultipartUploadSessionTypedValidatesResponseFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]interface{}
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "missing upload id",
|
||||
data: map[string]interface{}{
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
"block_num": 6,
|
||||
},
|
||||
wantText: "upload prepare failed: no upload_id returned",
|
||||
},
|
||||
{
|
||||
name: "missing block size",
|
||||
data: map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_num": 6,
|
||||
},
|
||||
wantText: "upload prepare failed: invalid block_size returned",
|
||||
},
|
||||
{
|
||||
name: "missing block num",
|
||||
data: map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
},
|
||||
wantText: "upload prepare failed: invalid block_num returned",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDriveMediaMultipartUploadSessionTyped(tt.data)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
|
||||
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadDriveMediaMultipartTypedPartAPIFailure(t *testing.T) {
|
||||
runtime, reg := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": float64(4 * 1024 * 1024),
|
||||
"block_num": float64(6),
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "chunk rejected",
|
||||
},
|
||||
})
|
||||
|
||||
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
|
||||
_, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: "large.bin",
|
||||
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
|
||||
ParentType: "ccm_import_open",
|
||||
ParentNode: "",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Code != 999 {
|
||||
t.Fatalf("category/code = %s/%d, want api/999", p.Category, p.Code)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, "upload media part failed: ") || !strings.Contains(p.Message, "chunk rejected") {
|
||||
t.Fatalf("message = %q, want action prefix and server msg", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadDriveMediaMultipartTypedFinishRequiresFileToken(t *testing.T) {
|
||||
runtime, reg := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": float64(4 * 1024 * 1024),
|
||||
"block_num": float64(6),
|
||||
},
|
||||
},
|
||||
})
|
||||
for i := 0; i < 6; i++ {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
|
||||
_, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: "large.bin",
|
||||
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
|
||||
ParentType: "ccm_import_open",
|
||||
ParentNode: "",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Message, "upload media finish failed: no file_token returned") {
|
||||
t.Fatalf("message = %q", p.Message)
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var DriveCover = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+cover",
|
||||
Description: "List or download stable cover presets for a Drive file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "Drive file token", Required: true},
|
||||
{Name: "spec", Desc: "cover preset: default | icon | grid | small | middle | big | square"},
|
||||
{Name: "version", Desc: "optional file version"},
|
||||
{Name: "list-only", Type: "bool", Desc: "list built-in cover specs without downloading"},
|
||||
{Name: "output", Desc: "local output path for downloaded cover"},
|
||||
{Name: "if-exists", Desc: "output conflict policy: error | overwrite | rename", Default: drivePreviewIfExistsError, Enum: []string{drivePreviewIfExistsError, drivePreviewIfExistsOverwrite, drivePreviewIfExistsRename}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
if err := validateDrivePreviewMode(runtime.Str("spec"), runtime.Bool("list-only"), runtime.Str("output"), "spec"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDrivePreviewIfExists(runtime.Str("if-exists")); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec := strings.TrimSpace(runtime.Str("spec")); spec != "" {
|
||||
if _, ok := findDriveCoverSpec(spec); !ok {
|
||||
return wrapDriveCoverUnavailable(spec)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
fileToken := runtime.Str("file-token")
|
||||
if runtime.Bool("list-only") {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List built-in cover specs (no API call)").
|
||||
Set("mode", "list").
|
||||
Set("file_token", fileToken).
|
||||
Set("candidates", buildDriveCoverListOutput(fileToken)["candidates"])
|
||||
}
|
||||
|
||||
spec, _ := findDriveCoverSpec(runtime.Str("spec"))
|
||||
params := buildDriveCoverDownloadParams(strings.TrimSpace(runtime.Str("version")), spec)
|
||||
dry := common.NewDryRunAPI().
|
||||
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("Download selected cover preset directly via preview_download").
|
||||
Params(params).
|
||||
Set("file_token", fileToken).
|
||||
Set("selected_spec", spec.Name).
|
||||
Set("output", runtime.Str("output"))
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := runtime.Str("file-token")
|
||||
version := strings.TrimSpace(runtime.Str("version"))
|
||||
requestedSpec := strings.TrimSpace(runtime.Str("spec"))
|
||||
outputPath := runtime.Str("output")
|
||||
ifExists := runtime.Str("if-exists")
|
||||
|
||||
if runtime.Bool("list-only") {
|
||||
runtime.Out(buildDriveCoverListOutput(fileToken), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
spec, ok := findDriveCoverSpec(requestedSpec)
|
||||
if !ok {
|
||||
return wrapDriveCoverUnavailable(requestedSpec)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Downloading cover %s for file %s\n", spec.Name, common.MaskToken(fileToken))
|
||||
result, err := downloadDrivePreviewArtifactWithParams(ctx, runtime, fileToken, buildDriveCoverDownloadParams(version, spec), outputPath, ifExists, spec.FallbackExt)
|
||||
if err != nil {
|
||||
return wrapDriveCoverDownloadError(err, spec.Name)
|
||||
}
|
||||
result["mode"] = "download"
|
||||
result["file_token"] = fileToken
|
||||
result["selected_spec"] = spec.Name
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// wrapDriveCoverDownloadError reclassifies preview_download HTTP 404 responses
|
||||
// on the +cover path as a failed precondition on --spec, because the Drive
|
||||
// shortcut contract documents 404 as "this file has no artifact for that cover
|
||||
// preset" rather than a transient transport failure.
|
||||
func wrapDriveCoverDownloadError(err error, requestedSpec string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Code != http.StatusNotFound {
|
||||
return err
|
||||
}
|
||||
hint := fmt.Sprintf(
|
||||
"This may mean no artifact exists for --spec %q, or that the file token/version is invalid. Verify the inputs, or rerun with `lark-cli drive +cover --file-token <file-token> --list-only`. Available cover specs: %s",
|
||||
requestedSpec,
|
||||
strings.Join(availableDriveCoverSpecs(), ", "),
|
||||
)
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeFailedPrecondition,
|
||||
"preview_download returned HTTP 404 for --spec %q",
|
||||
requestedSpec,
|
||||
).WithParam("--spec").WithCode(problem.Code).WithLogID(problem.LogID).WithHint(hint).WithCause(err)
|
||||
}
|
||||
@@ -39,230 +39,296 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveExportSpec(driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
})
|
||||
return ValidateExport(exportParamsFromFlags(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
// ExportParams holds the user-facing inputs for an export flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
|
||||
// the drive export implementation. An empty OutputDir means "create the export
|
||||
// task and poll, but do not download" — callers that only need the ready file
|
||||
// token / status get it back without writing a local file.
|
||||
type ExportParams struct {
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
SubID string
|
||||
OutputDir string
|
||||
FileName string
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
func (p ExportParams) spec() driveExportSpec {
|
||||
return driveExportSpec{
|
||||
Token: p.Token,
|
||||
DocType: p.DocType,
|
||||
FileExtension: p.FileExtension,
|
||||
SubID: p.SubID,
|
||||
}
|
||||
}
|
||||
|
||||
// exportParamsFromFlags reads the standard drive +export flag set.
|
||||
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
// drive +export always downloads; an empty --output-dir historically means
|
||||
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
|
||||
// it here to keep behavior identical and stay off the export-only ("" => skip
|
||||
// download) path that only sheets +workbook-export uses.
|
||||
outputDir := runtime.Str("output-dir")
|
||||
if outputDir == "" {
|
||||
outputDir = "."
|
||||
}
|
||||
return ExportParams{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OutputDir: outputDir,
|
||||
FileName: strings.TrimSpace(runtime.Str("file-name")),
|
||||
Overwrite: runtime.Bool("overwrite"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateExport runs the CLI-level export constraint checks.
|
||||
func ValidateExport(p ExportParams) error {
|
||||
return validateDriveExportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
|
||||
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
// RunExport drives create export task -> bounded poll -> optional download. It
|
||||
// is the shared core behind both drive +export and sheets +workbook-export. An
|
||||
// empty p.OutputDir skips the download step and returns the ready file token.
|
||||
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
|
||||
spec := p.spec()
|
||||
outputDir := p.OutputDir
|
||||
preferredFileName := strings.TrimSpace(p.FileName)
|
||||
overwrite := p.Overwrite
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
return err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
|
||||
// Export-only mode: caller wants the ready file token / metadata but
|
||||
// no local download (e.g. sheets +workbook-export without an output
|
||||
// path). Skip the download and return the status envelope.
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_token": status.FileToken,
|
||||
"file_name": status.FileName,
|
||||
"file_size": status.FileSize,
|
||||
"ready": true,
|
||||
"downloaded": false,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
},
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -488,6 +488,72 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
|
||||
// explicit empty --output-dir must still download to the current directory
|
||||
// (normalized to "."), not trigger the export-only no-download path that the
|
||||
// shared RunExport core uses for sheets +workbook-export.
|
||||
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_e",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0, "file_token": "box_e", "file_name": "report",
|
||||
"file_extension": "pdf", "type": "docx", "file_size": 3,
|
||||
},
|
||||
}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--output-dir", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Empty --output-dir must still write to cwd, not skip the download.
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
|
||||
if err != nil {
|
||||
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
|
||||
}
|
||||
if string(data) != "pdf" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"downloaded": false`) {
|
||||
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -34,128 +34,160 @@ var DriveImport = common.Shortcut{
|
||||
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveImportSpec(driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
})
|
||||
return ValidateImport(importParamsFromFlags(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
// ImportParams holds the user-facing inputs for an import flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-import) can reuse
|
||||
// the drive import implementation without taking a dependency on a --type flag.
|
||||
type ImportParams struct {
|
||||
File string
|
||||
DocType string
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string
|
||||
}
|
||||
|
||||
func (p ImportParams) spec() driveImportSpec {
|
||||
return driveImportSpec{
|
||||
FilePath: p.File,
|
||||
DocType: strings.ToLower(p.DocType),
|
||||
FolderToken: p.FolderToken,
|
||||
Name: p.Name,
|
||||
TargetToken: p.TargetToken,
|
||||
}
|
||||
}
|
||||
|
||||
// importParamsFromFlags reads the standard drive +import flag set.
|
||||
func importParamsFromFlags(runtime *common.RuntimeContext) ImportParams {
|
||||
return ImportParams{
|
||||
File: runtime.Str("file"),
|
||||
DocType: runtime.Str("type"),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateImport runs the CLI-level compatibility checks for an import.
|
||||
func ValidateImport(p ImportParams) error {
|
||||
return validateDriveImportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanImportDryRun builds the dry-run plan (upload -> create task -> poll) for
|
||||
// an import without performing any network or file I/O beyond a local stat.
|
||||
func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
}
|
||||
|
||||
// RunImport executes the full import flow: upload media -> create import task ->
|
||||
// bounded poll, then writes the result envelope to the runtime output. It is
|
||||
// the shared core behind both drive +import and sheets +workbook-import.
|
||||
func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportParams) error {
|
||||
spec := p.spec()
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
|
||||
// Keep dry-run and execution aligned on path normalization, file existence,
|
||||
// and format-specific size limits before planning the upload path.
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var DrivePreview = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+preview",
|
||||
Description: "List or download available preview artifacts for a Drive file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "Drive file token", Required: true},
|
||||
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source"},
|
||||
{Name: "version", Desc: "optional file version"},
|
||||
{Name: "list-only", Type: "bool", Desc: "list preview candidates without downloading"},
|
||||
{Name: "output", Desc: "local output path for downloaded preview"},
|
||||
{Name: "if-exists", Desc: "output conflict policy: error | overwrite | rename", Default: drivePreviewIfExistsError, Enum: []string{drivePreviewIfExistsError, drivePreviewIfExistsOverwrite, drivePreviewIfExistsRename}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
if err := validateDrivePreviewMode(runtime.Str("type"), runtime.Bool("list-only"), runtime.Str("output"), "type"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDrivePreviewIfExists(runtime.Str("if-exists"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
fileToken := runtime.Str("file-token")
|
||||
version := strings.TrimSpace(runtime.Str("version"))
|
||||
body := map[string]interface{}{}
|
||||
if version != "" {
|
||||
body["version"] = version
|
||||
}
|
||||
dry := common.NewDryRunAPI().
|
||||
POST("/open-apis/drive/v1/medias/:file_token/preview_result").
|
||||
Desc("[1] Fetch preview candidates for a Drive file").
|
||||
Set("file_token", fileToken)
|
||||
if len(body) > 0 {
|
||||
dry.Body(body)
|
||||
}
|
||||
if runtime.Bool("list-only") {
|
||||
return dry.Set("mode", "list")
|
||||
}
|
||||
downloadParams := map[string]interface{}{
|
||||
"preview_type": "<selected type_code from preview_result>",
|
||||
}
|
||||
if version != "" {
|
||||
downloadParams["version"] = version
|
||||
} else {
|
||||
downloadParams["version"] = "<resolved version from preview_result>"
|
||||
}
|
||||
return dry.
|
||||
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[2] Download the requested preview after selecting a matching candidate from preview_result").
|
||||
Params(downloadParams).
|
||||
Set("mode", "download").
|
||||
Set("requested_type", runtime.Str("type")).
|
||||
Set("output", runtime.Str("output"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := runtime.Str("file-token")
|
||||
version := strings.TrimSpace(runtime.Str("version"))
|
||||
requestedType := strings.TrimSpace(runtime.Str("type"))
|
||||
outputPath := runtime.Str("output")
|
||||
ifExists := runtime.Str("if-exists")
|
||||
|
||||
body := map[string]interface{}{}
|
||||
if version != "" {
|
||||
body["version"] = version
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Fetching preview candidates: %s\n", common.MaskToken(fileToken))
|
||||
data, candidates, err := fetchDrivePreviewCandidates(runtime, fileToken, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("list-only") {
|
||||
runtime.Out(buildDrivePreviewListOutput(fileToken, candidates), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
candidate, ok := selectDrivePreviewCandidate(candidates, requestedType)
|
||||
if !ok {
|
||||
return wrapDrivePreviewUnavailable(fileToken, requestedType, candidates, "")
|
||||
}
|
||||
if !candidate.Downloadable {
|
||||
return wrapDrivePreviewNotReady(fileToken, requestedType, candidate)
|
||||
}
|
||||
|
||||
downloadVersion := version
|
||||
if downloadVersion == "" {
|
||||
downloadVersion = versionString(data["version"])
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Downloading preview %s for file %s\n", candidate.Type, common.MaskToken(fileToken))
|
||||
result, err := downloadDrivePreviewArtifact(ctx, runtime, fileToken, candidate.TypeCode, downloadVersion, outputPath, ifExists, drivePreviewFallbackExt(candidate.Type))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result["mode"] = "download"
|
||||
result["file_token"] = fileToken
|
||||
result["selected_type"] = candidate.Type
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,813 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
drivePreviewIfExistsError = "error"
|
||||
drivePreviewIfExistsOverwrite = "overwrite"
|
||||
drivePreviewIfExistsRename = "rename"
|
||||
)
|
||||
|
||||
type drivePreviewCandidate struct {
|
||||
Type string
|
||||
TypeCode string
|
||||
TypeName string
|
||||
Label string
|
||||
Status string
|
||||
StatusCode string
|
||||
Downloadable bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
type driveCoverSpec struct {
|
||||
Name string
|
||||
Label string
|
||||
Description string
|
||||
PreviewType string
|
||||
BusType string
|
||||
Platform string
|
||||
Width int
|
||||
Height int
|
||||
Policy string
|
||||
FallbackExt string
|
||||
}
|
||||
|
||||
type driveExtensionResolution struct {
|
||||
Ext string
|
||||
Source string
|
||||
Detail string
|
||||
}
|
||||
|
||||
type drivePreviewTypeMeta struct {
|
||||
Code string
|
||||
Name string
|
||||
Type string
|
||||
Label string
|
||||
Aliases []string
|
||||
}
|
||||
|
||||
type drivePreviewStatusMeta struct {
|
||||
Code string
|
||||
Name string
|
||||
Reason string
|
||||
Downloadable bool
|
||||
}
|
||||
|
||||
var drivePreviewMimeToExt = map[string]string{
|
||||
"application/json": ".json",
|
||||
"application/msword": ".doc",
|
||||
"application/pdf": ".pdf",
|
||||
"application/xml": ".xml",
|
||||
"application/zip": ".zip",
|
||||
"image/bmp": ".bmp",
|
||||
"image/gif": ".gif",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/svg+xml": ".svg",
|
||||
"image/webp": ".webp",
|
||||
"text/csv": ".csv",
|
||||
"text/html": ".html",
|
||||
"text/plain": ".txt",
|
||||
"text/xml": ".xml",
|
||||
"video/mp4": ".mp4",
|
||||
"application/octet-stream": "",
|
||||
}
|
||||
|
||||
var drivePreviewTypes = []drivePreviewTypeMeta{
|
||||
{Code: "0", Name: "PDF", Type: "pdf", Label: "PDF Preview"},
|
||||
{Code: "1", Name: "PNG", Type: "png", Label: "PNG Preview", Aliases: []string{"image"}},
|
||||
{Code: "2", Name: "PAGES", Type: "pages", Label: "Paged Preview"},
|
||||
{Code: "3", Name: "VIDEO", Type: "video", Label: "Video Preview"},
|
||||
{Code: "4", Name: "MP4_360P", Type: "mp4_360p", Label: "MP4 360P Preview"},
|
||||
{Code: "5", Name: "MP4_480P", Type: "mp4_480p", Label: "MP4 480P Preview"},
|
||||
{Code: "6", Name: "MP4_720P", Type: "mp4_720p", Label: "MP4 720P Preview"},
|
||||
{Code: "7", Name: "JPG", Type: "jpg", Label: "JPG Preview", Aliases: []string{"image"}},
|
||||
{Code: "8", Name: "HTML", Type: "html", Label: "HTML Preview"},
|
||||
{Code: "9", Name: "PDF_LIN", Type: "pdf_lin", Label: "Linearized PDF Preview"},
|
||||
{Code: "10", Name: "XOD", Type: "xod", Label: "XOD Preview"},
|
||||
{Code: "11", Name: "JPG_LIN", Type: "jpg_lin", Label: "Linearized JPG Preview", Aliases: []string{"image"}},
|
||||
{Code: "12", Name: "PNG_LIN", Type: "png_lin", Label: "Linearized PNG Preview", Aliases: []string{"image"}},
|
||||
{Code: "13", Name: "ARCHIVE", Type: "archive", Label: "Archive Preview"},
|
||||
{Code: "14", Name: "TEXT", Type: "text", Label: "Text Preview"},
|
||||
{Code: "15", Name: "PDF_PART", Type: "pdf_part", Label: "Partial PDF Preview"},
|
||||
{Code: "16", Name: "SOURCE_FILE", Type: "source_file", Label: "Source File", Aliases: []string{"source"}},
|
||||
{Code: "17", Name: "VIDEO_META", Type: "video_meta", Label: "Video Metadata"},
|
||||
{Code: "18", Name: "WPS", Type: "wps", Label: "WPS Preview"},
|
||||
{Code: "19", Name: "SPLIT_PNG", Type: "split_png", Label: "Split PNG Preview", Aliases: []string{"image"}},
|
||||
{Code: "20", Name: "MEDIA_RESULT", Type: "media_result", Label: "Media Result"},
|
||||
{Code: "21", Name: "MIME", Type: "mime", Label: "MIME Type"},
|
||||
{Code: "22", Name: "SPILT_IMG_TXT", Type: "spilt_img_txt", Label: "Split Image Text"},
|
||||
{Code: "23", Name: "MP4_1080P", Type: "mp4_1080p", Label: "MP4 1080P Preview"},
|
||||
{Code: "24", Name: "IMAGE_META", Type: "image_meta", Label: "Image Metadata"},
|
||||
{Code: "25", Name: "DOC_PART", Type: "doc_part", Label: "Document Part"},
|
||||
{Code: "26", Name: "WATERMARK_PDF", Type: "watermark_pdf", Label: "Watermarked PDF Preview"},
|
||||
{Code: "27", Name: "FILE_WATERMARK", Type: "file_watermark", Label: "File Watermark"},
|
||||
}
|
||||
|
||||
var drivePreviewStatuses = []drivePreviewStatusMeta{
|
||||
{Code: "0", Name: "READY", Downloadable: true},
|
||||
{Code: "1", Name: "PROCESSING", Reason: "Preview is still processing."},
|
||||
{Code: "2", Name: "FAILED", Reason: "Preview generation failed."},
|
||||
{Code: "3", Name: "FAILED_NOT_RETRY", Reason: "Preview generation failed and will not retry."},
|
||||
{Code: "4", Name: "INVALID_EXTENTION", Reason: "File extension is invalid for this preview type."},
|
||||
{Code: "5", Name: "FILE_TOO_LARGE", Reason: "File is too large for preview generation."},
|
||||
{Code: "6", Name: "EMPTY_FILE", Reason: "File is empty."},
|
||||
{Code: "7", Name: "NO_SUPPORT", Reason: "Preview is not supported for this file."},
|
||||
{Code: "8", Name: "INVALID_PREVIEW_TYPE", Reason: "Preview type is invalid."},
|
||||
{Code: "9", Name: "NEED_PASSWORD", Reason: "Preview requires a password."},
|
||||
{Code: "10", Name: "FILE_INVALID", Reason: "File is invalid."},
|
||||
{Code: "11", Name: "TOO_MANY_PAGES", Reason: "File has too many pages for preview."},
|
||||
{Code: "1001", Name: "ARCHIVE_INVALID_FORMAT", Reason: "Archive format is invalid."},
|
||||
{Code: "1002", Name: "ARCHIVE_TOO_MANY_NODES", Reason: "Archive contains too many nodes."},
|
||||
{Code: "1003", Name: "ARCHIVE_TOO_MANY_NODES_PER_DIR", Reason: "Archive directory contains too many nodes."},
|
||||
{Code: "1004", Name: "THIRD_ENC_NO_PERMISSION", Reason: "No permission for third-party encrypted file."},
|
||||
{Code: "1006", Name: "NOT_SUPPORT_DECRYPT_THIRD_ENC_FILE", Reason: "Third-party encrypted file cannot be decrypted for preview."},
|
||||
}
|
||||
|
||||
var drivePreviewTypeByCode = func() map[string]drivePreviewTypeMeta {
|
||||
out := make(map[string]drivePreviewTypeMeta, len(drivePreviewTypes))
|
||||
for _, meta := range drivePreviewTypes {
|
||||
out[meta.Code] = meta
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
var drivePreviewStatusByCode = func() map[string]drivePreviewStatusMeta {
|
||||
out := make(map[string]drivePreviewStatusMeta, len(drivePreviewStatuses))
|
||||
for _, meta := range drivePreviewStatuses {
|
||||
out[meta.Code] = meta
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
var driveCoverSpecs = []driveCoverSpec{
|
||||
{
|
||||
Name: "default",
|
||||
Label: "Default Cover",
|
||||
Description: "Standard large cover (1280x1280).",
|
||||
PreviewType: "1",
|
||||
BusType: "cover",
|
||||
Platform: "pc",
|
||||
FallbackExt: ".png",
|
||||
},
|
||||
{
|
||||
Name: "icon",
|
||||
Label: "Icon",
|
||||
Description: "Small list icon (120x120).",
|
||||
PreviewType: "1",
|
||||
BusType: "icon",
|
||||
FallbackExt: ".png",
|
||||
},
|
||||
{
|
||||
Name: "grid",
|
||||
Label: "Grid Cover",
|
||||
Description: "Grid/card stream cover (360x360).",
|
||||
PreviewType: "1",
|
||||
BusType: "grid",
|
||||
FallbackExt: ".png",
|
||||
},
|
||||
{
|
||||
Name: "small",
|
||||
Label: "Small Graph",
|
||||
Description: "PC small graph cover (480x480).",
|
||||
PreviewType: "1",
|
||||
BusType: "small_graph",
|
||||
Platform: "pc",
|
||||
FallbackExt: ".png",
|
||||
},
|
||||
{
|
||||
Name: "middle",
|
||||
Label: "Middle Cover",
|
||||
Description: "Medium-sized cover (720x720).",
|
||||
PreviewType: "1",
|
||||
BusType: "middle",
|
||||
FallbackExt: ".png",
|
||||
},
|
||||
{
|
||||
Name: "big",
|
||||
Label: "Big Cover",
|
||||
Description: "Large mobile-oriented cover (850x850).",
|
||||
PreviewType: "1",
|
||||
BusType: "big",
|
||||
Platform: "mobile",
|
||||
FallbackExt: ".png",
|
||||
},
|
||||
{
|
||||
Name: "square",
|
||||
Label: "Square Cover",
|
||||
Description: "Square-cropped grid cover (360x360).",
|
||||
PreviewType: "1",
|
||||
Width: 360,
|
||||
Height: 360,
|
||||
Policy: "near",
|
||||
FallbackExt: ".png",
|
||||
},
|
||||
}
|
||||
|
||||
// validateDrivePreviewMode checks the required flag combinations for list and
|
||||
// download modes.
|
||||
func validateDrivePreviewMode(selected string, listOnly bool, outputPath, flagName string) error {
|
||||
selected = strings.TrimSpace(selected)
|
||||
outputPath = strings.TrimSpace(outputPath)
|
||||
selectedFlag := "--" + flagName
|
||||
if listOnly {
|
||||
if selected != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be combined with --list-only", selectedFlag).WithParam(selectedFlag)
|
||||
}
|
||||
if outputPath != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be combined with --list-only").WithParam("--output")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if selected == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "either --list-only or %s is required", selectedFlag).WithParam(selectedFlag)
|
||||
}
|
||||
if outputPath == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output is required when %s is set", selectedFlag).WithParam("--output")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDrivePreviewIfExists validates the accepted overwrite policy values.
|
||||
func validateDrivePreviewIfExists(policy string) error {
|
||||
switch strings.TrimSpace(policy) {
|
||||
case "", drivePreviewIfExistsError, drivePreviewIfExistsOverwrite, drivePreviewIfExistsRename:
|
||||
return nil
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --if-exists %q: allowed values are error, overwrite, rename", policy).WithParam("--if-exists")
|
||||
}
|
||||
}
|
||||
|
||||
// fetchDrivePreviewCandidates loads preview_result data and normalizes the
|
||||
// returned candidate list.
|
||||
func fetchDrivePreviewCandidates(runtime *common.RuntimeContext, fileToken string, body map[string]interface{}) (map[string]interface{}, []drivePreviewCandidate, error) {
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_result", validate.EncodePathSegment(fileToken)),
|
||||
nil,
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, normalizeDrivePreviewCandidates(data), nil
|
||||
}
|
||||
|
||||
// normalizeDrivePreviewCandidates converts preview_result items into internal
|
||||
// candidate records with stable type and status metadata.
|
||||
func normalizeDrivePreviewCandidates(data map[string]interface{}) []drivePreviewCandidate {
|
||||
items := common.GetSlice(data, "preview_results")
|
||||
candidates := make([]drivePreviewCandidate, 0, len(items))
|
||||
for _, item := range items {
|
||||
raw, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typeCode := firstString(raw, "preview_type", "type_code", "type")
|
||||
statusCode := firstString(raw, "preview_status", "status_code", "status")
|
||||
candidate := drivePreviewCandidate{
|
||||
TypeCode: typeCode,
|
||||
StatusCode: statusCode,
|
||||
Reason: strings.TrimSpace(firstString(raw, "reason", "status_msg", "message", "msg", "detail")),
|
||||
}
|
||||
applyDrivePreviewTypeMeta(&candidate)
|
||||
applyDrivePreviewStatusMeta(&candidate)
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// selectDrivePreviewCandidate matches a requested preview type or alias against
|
||||
// the available candidates.
|
||||
func selectDrivePreviewCandidate(candidates []drivePreviewCandidate, requested string) (drivePreviewCandidate, bool) {
|
||||
requested = normalizeDrivePreviewRequest(requested)
|
||||
if requested == "" {
|
||||
return drivePreviewCandidate{}, false
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if requested == candidate.Type || requested == strings.ToLower(candidate.TypeName) || requested == strings.ToLower(strings.TrimSpace(candidate.TypeCode)) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
|
||||
var firstAliasMatch drivePreviewCandidate
|
||||
hasAliasMatch := false
|
||||
for _, candidate := range candidates {
|
||||
if !slices.Contains(previewAliasesForCandidate(candidate), requested) {
|
||||
continue
|
||||
}
|
||||
if candidate.Downloadable {
|
||||
return candidate, true
|
||||
}
|
||||
if !hasAliasMatch {
|
||||
firstAliasMatch = candidate
|
||||
hasAliasMatch = true
|
||||
}
|
||||
}
|
||||
if hasAliasMatch {
|
||||
return firstAliasMatch, true
|
||||
}
|
||||
return drivePreviewCandidate{}, false
|
||||
}
|
||||
|
||||
// buildDrivePreviewListOutput formats preview candidates for --list-only
|
||||
// responses.
|
||||
func buildDrivePreviewListOutput(fileToken string, candidates []drivePreviewCandidate) map[string]interface{} {
|
||||
items := make([]map[string]interface{}, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
item := map[string]interface{}{
|
||||
"type": candidate.Type,
|
||||
"type_code": candidate.TypeCode,
|
||||
"label": candidate.Label,
|
||||
"status": candidate.Status,
|
||||
"status_code": candidate.StatusCode,
|
||||
"downloadable": candidate.Downloadable,
|
||||
}
|
||||
if candidate.Reason != "" {
|
||||
item["reason"] = candidate.Reason
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"mode": "list",
|
||||
"file_token": fileToken,
|
||||
"candidates": items,
|
||||
}
|
||||
if len(items) > 0 {
|
||||
out["next_action"] = "select one candidate and rerun with --type plus --output"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildDriveCoverListOutput formats the built-in cover specs for --list-only
|
||||
// responses.
|
||||
func buildDriveCoverListOutput(fileToken string) map[string]interface{} {
|
||||
items := make([]map[string]interface{}, 0, len(driveCoverSpecs))
|
||||
for _, spec := range driveCoverSpecs {
|
||||
item := map[string]interface{}{
|
||||
"spec": spec.Name,
|
||||
"label": spec.Label,
|
||||
}
|
||||
if spec.Description != "" {
|
||||
item["description"] = spec.Description
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"mode": "list",
|
||||
"file_token": fileToken,
|
||||
"candidates": items,
|
||||
"next_action": "select one spec and rerun with --spec plus --output",
|
||||
}
|
||||
}
|
||||
|
||||
// findDriveCoverSpec resolves a cover spec by its user-facing name.
|
||||
func findDriveCoverSpec(name string) (driveCoverSpec, bool) {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
for _, spec := range driveCoverSpecs {
|
||||
if spec.Name == name {
|
||||
return spec, true
|
||||
}
|
||||
}
|
||||
return driveCoverSpec{}, false
|
||||
}
|
||||
|
||||
// buildDriveCoverDownloadParams translates a cover spec into preview_download
|
||||
// query parameters.
|
||||
func buildDriveCoverDownloadParams(version string, spec driveCoverSpec) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"preview_type": spec.PreviewType,
|
||||
}
|
||||
if strings.TrimSpace(spec.BusType) != "" {
|
||||
params["bus_type"] = spec.BusType
|
||||
}
|
||||
if strings.TrimSpace(spec.Platform) != "" {
|
||||
params["platform"] = spec.Platform
|
||||
}
|
||||
if spec.Width > 0 {
|
||||
params["width"] = spec.Width
|
||||
}
|
||||
if spec.Height > 0 {
|
||||
params["height"] = spec.Height
|
||||
}
|
||||
if strings.TrimSpace(spec.Policy) != "" {
|
||||
params["policy"] = spec.Policy
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
params["version"] = version
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// downloadDrivePreviewArtifact downloads a preview artifact for a single
|
||||
// preview_type value.
|
||||
func downloadDrivePreviewArtifact(ctx context.Context, runtime *common.RuntimeContext, fileToken, previewType, version, outputPath, ifExists, fallbackExt string) (map[string]interface{}, error) {
|
||||
query := map[string]interface{}{
|
||||
"preview_type": previewType,
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
query["version"] = version
|
||||
}
|
||||
return downloadDrivePreviewArtifactWithParams(ctx, runtime, fileToken, query, outputPath, ifExists, fallbackExt)
|
||||
}
|
||||
|
||||
// downloadDrivePreviewArtifactWithParams downloads a preview artifact using the
|
||||
// provided preview_download query parameters and writes it to the local path.
|
||||
func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common.RuntimeContext, fileToken string, query map[string]interface{}, outputPath, ifExists, fallbackExt string) (map[string]interface{}, error) {
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
|
||||
}
|
||||
|
||||
queryParams := make(larkcore.QueryParams, len(query))
|
||||
for key, value := range query {
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
queryParams[key] = []string{text}
|
||||
}
|
||||
|
||||
apiReq := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
|
||||
QueryParams: queryParams,
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, wrapDriveNetworkErr(err, "preview download failed: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return nil, driveSaveError(err)
|
||||
}
|
||||
|
||||
savedPath, _ := runtime.ResolveSavePath(finalPath)
|
||||
if savedPath == "" {
|
||||
savedPath = finalPath
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"output_path": savedPath,
|
||||
"size_bytes": result.Size(),
|
||||
"content_type": resp.Header.Get("Content-Type"),
|
||||
"status": "READY",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveDrivePreviewOutputPath finalizes the save path, applying extension
|
||||
// inference and the selected collision policy.
|
||||
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists string) (string, *driveExtensionResolution, error) {
|
||||
finalPath, resolution := autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
|
||||
}
|
||||
|
||||
switch ifExists {
|
||||
case "", drivePreviewIfExistsError:
|
||||
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --if-exists overwrite or rename)", finalPath).WithParam("--output")
|
||||
} else if !errors.Is(statErr, fs.ErrNotExist) {
|
||||
return "", nil, errs.NewInternalError(errs.SubtypeFileIO, "cannot access output path %s: %s", finalPath, statErr).WithCause(statErr)
|
||||
}
|
||||
return finalPath, resolution, nil
|
||||
case drivePreviewIfExistsOverwrite:
|
||||
return finalPath, resolution, nil
|
||||
case drivePreviewIfExistsRename:
|
||||
renamed, err := nextAvailableDrivePreviewPath(runtime.FileIO(), finalPath)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(renamed); err != nil {
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
|
||||
}
|
||||
return renamed, resolution, nil
|
||||
default:
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --if-exists %q: allowed values are error, overwrite, rename", ifExists).WithParam("--if-exists")
|
||||
}
|
||||
}
|
||||
|
||||
// nextAvailableDrivePreviewPath finds the first unused "name (n)" variant for a
|
||||
// target output path.
|
||||
func nextAvailableDrivePreviewPath(fio fileio.FileIO, path string) (string, error) {
|
||||
if _, err := fio.Stat(path); err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return path, nil
|
||||
}
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot access output path %s: %s", path, err).WithCause(err)
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
ext := filepath.Ext(path)
|
||||
base := strings.TrimSuffix(filepath.Base(path), ext)
|
||||
for i := 1; i < 10000; i++ {
|
||||
candidate := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, i, ext))
|
||||
if _, err := fio.Stat(candidate); err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return candidate, nil
|
||||
}
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot access candidate output path %s: %s", candidate, err).WithCause(err)
|
||||
}
|
||||
}
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot allocate a unique output path for %s", path)
|
||||
}
|
||||
|
||||
// autoAppendDrivePreviewExtension appends an inferred extension when the user
|
||||
// did not provide one explicitly.
|
||||
func autoAppendDrivePreviewExtension(outputPath string, header http.Header, fallbackExt string) (string, *driveExtensionResolution) {
|
||||
if drivePreviewHasExplicitExtension(outputPath) {
|
||||
return outputPath, nil
|
||||
}
|
||||
normalizedPath := outputPath
|
||||
if filepath.Ext(outputPath) == "." {
|
||||
normalizedPath = strings.TrimSuffix(outputPath, ".")
|
||||
}
|
||||
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
|
||||
return normalizedPath + resolution.Ext, resolution
|
||||
}
|
||||
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
|
||||
return normalizedPath + resolution.Ext, resolution
|
||||
}
|
||||
if fallbackExt != "" {
|
||||
return normalizedPath + fallbackExt, &driveExtensionResolution{
|
||||
Ext: fallbackExt,
|
||||
Source: "fallback",
|
||||
Detail: "default fallback",
|
||||
}
|
||||
}
|
||||
return outputPath, nil
|
||||
}
|
||||
|
||||
// drivePreviewHasExplicitExtension reports whether the path already ends with a
|
||||
// usable filename extension.
|
||||
func drivePreviewHasExplicitExtension(path string) bool {
|
||||
ext := filepath.Ext(path)
|
||||
return ext != "" && ext != "."
|
||||
}
|
||||
|
||||
// drivePreviewExtensionByContentType maps a response Content-Type header to a
|
||||
// file extension when possible.
|
||||
func drivePreviewExtensionByContentType(contentType string) *driveExtensionResolution {
|
||||
if contentType == "" {
|
||||
return nil
|
||||
}
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0])
|
||||
}
|
||||
if ext, ok := drivePreviewMimeToExt[strings.ToLower(mediaType)]; ok && ext != "" {
|
||||
return &driveExtensionResolution{
|
||||
Ext: ext,
|
||||
Source: "Content-Type",
|
||||
Detail: contentType,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drivePreviewExtensionByContentDisposition extracts an extension from the
|
||||
// response filename metadata.
|
||||
func drivePreviewExtensionByContentDisposition(header http.Header) *driveExtensionResolution {
|
||||
filename := strings.TrimSpace(larkcore.FileNameByHeader(header))
|
||||
if filename == "" {
|
||||
return nil
|
||||
}
|
||||
ext := filepath.Ext(filename)
|
||||
if ext == "" || ext == "." {
|
||||
return nil
|
||||
}
|
||||
return &driveExtensionResolution{
|
||||
Ext: ext,
|
||||
Source: "Content-Disposition",
|
||||
Detail: filename,
|
||||
}
|
||||
}
|
||||
|
||||
// drivePreviewFallbackExt returns the default extension for known preview type
|
||||
// aliases when headers do not provide one.
|
||||
func drivePreviewFallbackExt(alias string) string {
|
||||
switch normalizeDrivePreviewRequest(alias) {
|
||||
case "pdf":
|
||||
return ".pdf"
|
||||
case "html":
|
||||
return ".html"
|
||||
case "text":
|
||||
return ".txt"
|
||||
case "png", "png_lin", "split_png":
|
||||
return ".png"
|
||||
case "jpg", "jpg_lin":
|
||||
return ".jpg"
|
||||
case "source", "source_file":
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// applyDrivePreviewTypeMeta fills normalized type metadata from the preview
|
||||
// type code.
|
||||
func applyDrivePreviewTypeMeta(candidate *drivePreviewCandidate) {
|
||||
if candidate == nil {
|
||||
return
|
||||
}
|
||||
if meta, ok := drivePreviewTypeByCode[candidate.TypeCode]; ok {
|
||||
candidate.Type = meta.Type
|
||||
candidate.TypeName = meta.Name
|
||||
candidate.Label = meta.Label
|
||||
return
|
||||
}
|
||||
code := strings.TrimSpace(candidate.TypeCode)
|
||||
if code == "" {
|
||||
candidate.Type = "unknown"
|
||||
candidate.TypeName = "UNKNOWN"
|
||||
candidate.Label = "Unknown Preview Type"
|
||||
return
|
||||
}
|
||||
candidate.Type = "unknown_" + code
|
||||
candidate.TypeName = "UNKNOWN"
|
||||
candidate.Label = fmt.Sprintf("Unknown Preview Type %s", code)
|
||||
}
|
||||
|
||||
// applyDrivePreviewStatusMeta fills normalized status metadata from the preview
|
||||
// status code.
|
||||
func applyDrivePreviewStatusMeta(candidate *drivePreviewCandidate) {
|
||||
if candidate == nil {
|
||||
return
|
||||
}
|
||||
if meta, ok := drivePreviewStatusByCode[candidate.StatusCode]; ok {
|
||||
candidate.Status = meta.Name
|
||||
candidate.Downloadable = meta.Downloadable
|
||||
if candidate.Reason == "" && !meta.Downloadable {
|
||||
candidate.Reason = meta.Reason
|
||||
}
|
||||
if meta.Downloadable {
|
||||
candidate.Reason = ""
|
||||
}
|
||||
return
|
||||
}
|
||||
candidate.Status = "UNKNOWN"
|
||||
candidate.Downloadable = false
|
||||
if candidate.Reason == "" {
|
||||
if strings.TrimSpace(candidate.StatusCode) == "" {
|
||||
candidate.Reason = "Preview status is missing."
|
||||
} else {
|
||||
candidate.Reason = fmt.Sprintf("Unknown preview status %s.", candidate.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeDrivePreviewRequest canonicalizes user input for preview type
|
||||
// matching.
|
||||
func normalizeDrivePreviewRequest(requested string) string {
|
||||
requested = strings.ToLower(strings.TrimSpace(requested))
|
||||
requested = strings.ReplaceAll(requested, "-", "_")
|
||||
requested = strings.ReplaceAll(requested, " ", "_")
|
||||
return requested
|
||||
}
|
||||
|
||||
// previewAliasesForCandidate returns configured aliases for a preview
|
||||
// candidate's type code.
|
||||
func previewAliasesForCandidate(candidate drivePreviewCandidate) []string {
|
||||
if meta, ok := drivePreviewTypeByCode[candidate.TypeCode]; ok {
|
||||
return meta.Aliases
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// firstString returns the first non-empty string-like value from the provided
|
||||
// keys.
|
||||
func firstString(m map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(t) != "" {
|
||||
return t
|
||||
}
|
||||
case fmt.Stringer:
|
||||
if s := strings.TrimSpace(t.String()); s != "" {
|
||||
return s
|
||||
}
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// versionString normalizes version fields from heterogeneous API payload types.
|
||||
func versionString(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// availableDrivePreviewTypes lists unique normalized preview type names from
|
||||
// the candidate set.
|
||||
func availableDrivePreviewTypes(candidates []drivePreviewCandidate) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
name := strings.TrimSpace(candidate.Type)
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// availableDriveCoverSpecs lists the supported built-in cover spec names.
|
||||
func availableDriveCoverSpecs() []string {
|
||||
out := make([]string, 0, len(driveCoverSpecs))
|
||||
for _, spec := range driveCoverSpecs {
|
||||
out = append(out, spec.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wrapDrivePreviewUnavailable builds a validation error for an unsupported
|
||||
// preview selection.
|
||||
func wrapDrivePreviewUnavailable(fileToken, requested string, candidates []drivePreviewCandidate, reason string) error {
|
||||
available := availableDrivePreviewTypes(candidates)
|
||||
if reason == "" {
|
||||
reason = fmt.Sprintf("requested preview type %q is not available for file %s", requested, fileToken)
|
||||
}
|
||||
hint := "rerun with --list-only to inspect available preview types"
|
||||
if len(available) > 0 {
|
||||
hint = fmt.Sprintf("available preview types: %s", strings.Join(available, ", "))
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
|
||||
}
|
||||
|
||||
// wrapDrivePreviewNotReady builds an actionable error for a preview candidate
|
||||
// that exists but is not yet downloadable.
|
||||
func wrapDrivePreviewNotReady(fileToken, requested string, candidate drivePreviewCandidate) error {
|
||||
reason := candidate.Reason
|
||||
if reason == "" {
|
||||
reason = fmt.Sprintf("preview type %q is not downloadable yet (status=%s)", requested, candidate.Status)
|
||||
}
|
||||
hint := fmt.Sprintf("rerun `lark-cli drive +preview --file-token %s --list-only` to inspect current candidate status", fileToken)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
|
||||
}
|
||||
|
||||
// wrapDriveCoverUnavailable builds a validation error for an unknown cover
|
||||
// spec.
|
||||
func wrapDriveCoverUnavailable(requested string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --spec %q", requested).
|
||||
WithHint("available cover specs: %s", strings.Join(availableDriveCoverSpecs(), ", ")).
|
||||
WithParam("--spec")
|
||||
}
|
||||
@@ -1,926 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestDrivePreviewListOnlyNormalizesCandidates verifies list mode output is
|
||||
// normalized from preview_result payloads.
|
||||
func TestDrivePreviewListOnlyNormalizesCandidates(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"preview_results": []map[string]interface{}{
|
||||
{"preview_type": 0, "preview_status": 0},
|
||||
{"preview_type": 14, "preview_status": 1},
|
||||
{"preview_type": 16, "preview_status": 7},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_preview",
|
||||
"--list-only",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got := data["mode"]; got != "list" {
|
||||
t.Fatalf("mode=%v, want list", got)
|
||||
}
|
||||
candidates, _ := data["candidates"].([]interface{})
|
||||
if len(candidates) != 3 {
|
||||
t.Fatalf("len(candidates)=%d, want 3", len(candidates))
|
||||
}
|
||||
first, _ := candidates[0].(map[string]interface{})
|
||||
if got := first["type"]; got != "pdf" {
|
||||
t.Fatalf("candidate[0].type=%v, want pdf", got)
|
||||
}
|
||||
if got := first["type_code"]; got != "0" {
|
||||
t.Fatalf("candidate[0].type_code=%v, want 0", got)
|
||||
}
|
||||
if got := first["status"]; got != "READY" {
|
||||
t.Fatalf("candidate[0].status=%v, want READY", got)
|
||||
}
|
||||
if got := first["downloadable"]; got != true {
|
||||
t.Fatalf("candidate[0].downloadable=%v, want true", got)
|
||||
}
|
||||
second, _ := candidates[1].(map[string]interface{})
|
||||
if got := second["status_code"]; got != "1" {
|
||||
t.Fatalf("candidate[1].status_code=%v, want 1", got)
|
||||
}
|
||||
if got := second["reason"]; got != "Preview is still processing." {
|
||||
t.Fatalf("candidate[1].reason=%v, want processing reason", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy verifies preview
|
||||
// downloads use the resolved type and rename collision handling.
|
||||
func TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"version": 7,
|
||||
"preview_results": []map[string]interface{}{
|
||||
{"preview_type": 0, "preview_status": 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/file_preview/preview_download?preview_type=0",
|
||||
Status: 200,
|
||||
Body: []byte("%PDF-1.7"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "report.pdf"), []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_preview",
|
||||
"--type", "pdf",
|
||||
"--output", "report",
|
||||
"--if-exists", "rename",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got := data["selected_type"]; got != "pdf" {
|
||||
t.Fatalf("selected_type=%v, want pdf", got)
|
||||
}
|
||||
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks() error: %v", err)
|
||||
}
|
||||
wantPath := filepath.Join(resolvedTmpDir, "report (1).pdf")
|
||||
if got := data["output_path"]; got != wantPath {
|
||||
t.Fatalf("output_path=%v, want %s", got, wantPath)
|
||||
}
|
||||
if _, err := os.Stat(wantPath); err != nil {
|
||||
t.Fatalf("expected preview artifact at %q: %v", wantPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewRejectsUnavailableType verifies unavailable preview types
|
||||
// return an actionable validation error.
|
||||
func TestDrivePreviewRejectsUnavailableType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"preview_results": []map[string]interface{}{
|
||||
{"preview_type": 8, "preview_status": 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_preview",
|
||||
"--type", "pdf",
|
||||
"--output", "report",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected unavailable type error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `requested preview type "pdf" is not available`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectDrivePreviewCandidatePrefersDownloadableAliasMatch verifies alias
|
||||
// selection prefers a downloadable candidate over an earlier unavailable one.
|
||||
func TestSelectDrivePreviewCandidatePrefersDownloadableAliasMatch(t *testing.T) {
|
||||
candidate, ok := selectDrivePreviewCandidate([]drivePreviewCandidate{
|
||||
{Type: "png", TypeCode: "1", Downloadable: false, Status: "PROCESSING"},
|
||||
{Type: "jpg", TypeCode: "7", Downloadable: true, Status: "READY"},
|
||||
}, "image")
|
||||
if !ok {
|
||||
t.Fatal("expected alias match, got none")
|
||||
}
|
||||
if candidate.Type != "jpg" {
|
||||
t.Fatalf("selected candidate=%q, want jpg", candidate.Type)
|
||||
}
|
||||
if !candidate.Downloadable {
|
||||
t.Fatalf("selected candidate should be downloadable: %+v", candidate)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverListOnlyUsesStaticSpecs verifies cover list mode returns the
|
||||
// built-in spec catalog without calling APIs.
|
||||
func TestDriveCoverListOnlyUsesStaticSpecs(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveCover, []string{
|
||||
"+cover",
|
||||
"--file-token", "file_cover",
|
||||
"--list-only",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
candidates, _ := data["candidates"].([]interface{})
|
||||
if len(candidates) != len(driveCoverSpecs) {
|
||||
t.Fatalf("len(candidates)=%d, want %d", len(candidates), len(driveCoverSpecs))
|
||||
}
|
||||
last, _ := candidates[len(candidates)-1].(map[string]interface{})
|
||||
if got := last["spec"]; got != "square" {
|
||||
t.Fatalf("last spec=%v, want square", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverDownloadUsesMappedCoverOptionAndPreviewType verifies cover
|
||||
// downloads send the expected preview_download query mapping.
|
||||
func TestDriveCoverDownloadUsesMappedCoverOptionAndPreviewType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
var capturedQuery url.Values
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/file_cover/preview_download",
|
||||
Status: 200,
|
||||
Body: []byte("png-data"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"image/png"},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveCover, []string{
|
||||
"+cover",
|
||||
"--file-token", "file_cover",
|
||||
"--spec", "square",
|
||||
"--output", "cover",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got := data["selected_spec"]; got != "square" {
|
||||
t.Fatalf("selected_spec=%v, want square", got)
|
||||
}
|
||||
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks() error: %v", err)
|
||||
}
|
||||
wantPath := filepath.Join(resolvedTmpDir, "cover.png")
|
||||
if got := data["output_path"]; got != wantPath {
|
||||
t.Fatalf("output_path=%v, want %s", got, wantPath)
|
||||
}
|
||||
if _, err := os.Stat(wantPath); err != nil {
|
||||
t.Fatalf("expected cover file at %q: %v", wantPath, err)
|
||||
}
|
||||
if got := capturedQuery.Get("preview_type"); got != "1" {
|
||||
t.Fatalf("preview_type=%q, want 1", got)
|
||||
}
|
||||
if got := capturedQuery.Get("bus_type"); got != "" {
|
||||
t.Fatalf("bus_type=%q, want empty for square crop flow", got)
|
||||
}
|
||||
if got := capturedQuery.Get("platform"); got != "" {
|
||||
t.Fatalf("platform=%q, want empty when using default platform", got)
|
||||
}
|
||||
if got := capturedQuery.Get("width"); got != "360" {
|
||||
t.Fatalf("width=%q, want 360", got)
|
||||
}
|
||||
if got := capturedQuery.Get("height"); got != "360" {
|
||||
t.Fatalf("height=%q, want 360", got)
|
||||
}
|
||||
if got := capturedQuery.Get("policy"); got != "near" {
|
||||
t.Fatalf("policy=%q, want near", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverDownload404ReturnsFailedPrecondition verifies the +cover path
|
||||
// reclassifies preview_download HTTP 404 as a non-retryable spec/state issue.
|
||||
func TestDriveCoverDownload404ReturnsFailedPrecondition(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/file_cover/preview_download",
|
||||
Status: http.StatusNotFound,
|
||||
Body: []byte(`{"code":404,"msg":"no artifact"}`),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveCover, []string{
|
||||
"+cover",
|
||||
"--file-token", "file_cover",
|
||||
"--spec", "square",
|
||||
"--output", "cover",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected cover 404 error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype=%q, want %q", validationErr.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if validationErr.Param != "--spec" {
|
||||
t.Fatalf("param=%q, want --spec", validationErr.Param)
|
||||
}
|
||||
if validationErr.Code != http.StatusNotFound {
|
||||
t.Fatalf("code=%d, want %d", validationErr.Code, http.StatusNotFound)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "--list-only") {
|
||||
t.Fatalf("hint=%q, want --list-only guidance", validationErr.Hint)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "file token/version is invalid") {
|
||||
t.Fatalf("hint=%q, want invalid file token/version guidance", validationErr.Hint)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "available cover specs") && !strings.Contains(validationErr.Hint, "default, icon, grid") {
|
||||
t.Fatalf("hint=%q, want available cover specs guidance", validationErr.Hint)
|
||||
}
|
||||
if !strings.Contains(validationErr.Error(), `preview_download returned HTTP 404 for --spec "square"`) {
|
||||
t.Fatalf("message=%q, want neutral 404 message", validationErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// newDrivePreviewRuntime builds a shortcut runtime with preconfigured preview
|
||||
// and cover flags for DryRun and helper tests.
|
||||
func newDrivePreviewRuntime(t *testing.T, use string, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: use}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("spec", "", "")
|
||||
cmd.Flags().String("version", "", "")
|
||||
cmd.Flags().String("output", "", "")
|
||||
cmd.Flags().String("if-exists", drivePreviewIfExistsError, "")
|
||||
cmd.Flags().Bool("list-only", false, "")
|
||||
for name, value := range stringFlags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
for name, value := range boolFlags {
|
||||
if !value {
|
||||
continue
|
||||
}
|
||||
if err := cmd.Flags().Set(name, "true"); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContextWithCtx(context.Background(), cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
// decodeDryRunOutput marshals a DryRunAPI helper into a generic map for test
|
||||
// assertions.
|
||||
func decodeDryRunOutput(t *testing.T, dry *common.DryRunAPI) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
raw, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRunIncludesVersionAndMode verifies preview DryRun records
|
||||
// versioned request metadata in download mode.
|
||||
func TestDrivePreviewDryRunIncludesVersionAndMode(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
|
||||
"file-token": "file_preview",
|
||||
"type": "image",
|
||||
"version": "7",
|
||||
"output": "preview",
|
||||
}, nil)
|
||||
|
||||
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
|
||||
if got := data["mode"]; got != "download" {
|
||||
t.Fatalf("mode=%v, want download", got)
|
||||
}
|
||||
if got := data["requested_type"]; got != "image" {
|
||||
t.Fatalf("requested_type=%v, want image", got)
|
||||
}
|
||||
api, _ := data["api"].([]interface{})
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("len(api)=%d, want 2", len(api))
|
||||
}
|
||||
call, _ := api[0].(map[string]interface{})
|
||||
if got := call["method"]; got != "POST" {
|
||||
t.Fatalf("method=%v, want POST", got)
|
||||
}
|
||||
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_preview/preview_result" {
|
||||
t.Fatalf("url=%v, want preview_result", got)
|
||||
}
|
||||
body, _ := call["body"].(map[string]interface{})
|
||||
if got := body["version"]; got != "7" {
|
||||
t.Fatalf("body.version=%v, want 7", got)
|
||||
}
|
||||
downloadCall, _ := api[1].(map[string]interface{})
|
||||
if got := downloadCall["method"]; got != "GET" {
|
||||
t.Fatalf("download method=%v, want GET", got)
|
||||
}
|
||||
if got := downloadCall["url"]; got != "/open-apis/drive/v1/medias/file_preview/preview_download" {
|
||||
t.Fatalf("download url=%v, want preview_download", got)
|
||||
}
|
||||
params, _ := downloadCall["params"].(map[string]interface{})
|
||||
if got := params["preview_type"]; got != "<selected type_code from preview_result>" {
|
||||
t.Fatalf("download params.preview_type=%v, want placeholder", got)
|
||||
}
|
||||
if got := params["version"]; got != "7" {
|
||||
t.Fatalf("download params.version=%v, want 7", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRunListOmitsBodyWithoutVersion verifies list-mode DryRun
|
||||
// omits the request body when no version is supplied.
|
||||
func TestDrivePreviewDryRunListOmitsBodyWithoutVersion(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
|
||||
"file-token": "file_preview",
|
||||
}, map[string]bool{"list-only": true})
|
||||
|
||||
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
|
||||
if got := data["mode"]; got != "list" {
|
||||
t.Fatalf("mode=%v, want list", got)
|
||||
}
|
||||
api, _ := data["api"].([]interface{})
|
||||
call, _ := api[0].(map[string]interface{})
|
||||
if _, ok := call["body"]; ok {
|
||||
t.Fatalf("dry-run body should be omitted when version is empty: %#v", call)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRunDownloadWithoutVersionShowsResolvedVersion verifies
|
||||
// download-mode DryRun documents the second preview_download step even when the
|
||||
// final version is only known after preview_result resolves candidates.
|
||||
func TestDrivePreviewDryRunDownloadWithoutVersionShowsResolvedVersion(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
|
||||
"file-token": "file_preview",
|
||||
"type": "pdf",
|
||||
"output": "preview",
|
||||
}, nil)
|
||||
|
||||
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
|
||||
api, _ := data["api"].([]interface{})
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("len(api)=%d, want 2", len(api))
|
||||
}
|
||||
downloadCall, _ := api[1].(map[string]interface{})
|
||||
params, _ := downloadCall["params"].(map[string]interface{})
|
||||
if got := params["version"]; got != "<resolved version from preview_result>" {
|
||||
t.Fatalf("download params.version=%v, want resolved-version placeholder", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverDryRunListAndDownload verifies cover DryRun output for both
|
||||
// list and download modes.
|
||||
func TestDriveCoverDryRunListAndDownload(t *testing.T) {
|
||||
listRuntime := newDrivePreviewRuntime(t, "drive +cover", map[string]string{
|
||||
"file-token": "file_cover",
|
||||
}, map[string]bool{"list-only": true})
|
||||
listData := decodeDryRunOutput(t, DriveCover.DryRun(context.Background(), listRuntime))
|
||||
if got := listData["mode"]; got != "list" {
|
||||
t.Fatalf("list mode=%v, want list", got)
|
||||
}
|
||||
if _, ok := listData["candidates"].([]interface{}); !ok {
|
||||
t.Fatalf("list candidates missing: %#v", listData)
|
||||
}
|
||||
|
||||
downloadRuntime := newDrivePreviewRuntime(t, "drive +cover", map[string]string{
|
||||
"file-token": "file_cover",
|
||||
"spec": "square",
|
||||
"version": "3",
|
||||
"output": "cover",
|
||||
}, nil)
|
||||
downloadData := decodeDryRunOutput(t, DriveCover.DryRun(context.Background(), downloadRuntime))
|
||||
if got := downloadData["selected_spec"]; got != "square" {
|
||||
t.Fatalf("selected_spec=%v, want square", got)
|
||||
}
|
||||
api, _ := downloadData["api"].([]interface{})
|
||||
call, _ := api[0].(map[string]interface{})
|
||||
params, _ := call["params"].(map[string]interface{})
|
||||
if got := params["width"]; got != float64(360) {
|
||||
t.Fatalf("params.width=%v, want 360", got)
|
||||
}
|
||||
if got := params["policy"]; got != "near" {
|
||||
t.Fatalf("params.policy=%v, want near", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverDryRunDefaultSpecIncludesVersionAndPlatform verifies DryRun
|
||||
// params include version and built-in platform metadata for default covers.
|
||||
func TestDriveCoverDryRunDefaultSpecIncludesVersionAndPlatform(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +cover", map[string]string{
|
||||
"file-token": "file_cover",
|
||||
"spec": "default",
|
||||
"version": "5",
|
||||
"output": "cover",
|
||||
}, nil)
|
||||
|
||||
data := decodeDryRunOutput(t, DriveCover.DryRun(context.Background(), runtime))
|
||||
api, _ := data["api"].([]interface{})
|
||||
call, _ := api[0].(map[string]interface{})
|
||||
params, _ := call["params"].(map[string]interface{})
|
||||
if got := params["bus_type"]; got != "cover" {
|
||||
t.Fatalf("params.bus_type=%v, want cover", got)
|
||||
}
|
||||
if got := params["platform"]; got != "pc" {
|
||||
t.Fatalf("params.platform=%v, want pc", got)
|
||||
}
|
||||
if got := params["version"]; got != "5" {
|
||||
t.Fatalf("params.version=%v, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewValidationErrors verifies preview flag validation rejects
|
||||
// incomplete and conflicting argument combinations.
|
||||
func TestDrivePreviewValidationErrors(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_preview",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "either --list-only or --type is required") {
|
||||
t.Fatalf("unexpected missing type error: %v", err)
|
||||
}
|
||||
|
||||
err = mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_preview",
|
||||
"--list-only",
|
||||
"--type", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "--type cannot be combined with --list-only") {
|
||||
t.Fatalf("unexpected list-only conflict: %v", err)
|
||||
}
|
||||
|
||||
err = mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_preview",
|
||||
"--type", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "--output is required when --type is set") {
|
||||
t.Fatalf("unexpected missing output error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewNotReadyReturnsFailedPrecondition verifies a known but
|
||||
// unready preview candidate returns a failed-precondition error.
|
||||
func TestDrivePreviewNotReadyReturnsFailedPrecondition(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"preview_results": []map[string]interface{}{
|
||||
{"preview_type": 1, "preview_status": 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_preview",
|
||||
"--type", "image",
|
||||
"--output", "preview",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected not-ready error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype=%q, want %q", validationErr.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if validationErr.Param != "--type" {
|
||||
t.Fatalf("param=%q, want --type", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "--list-only") {
|
||||
t.Fatalf("hint=%q, want list-only guidance", validationErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverRejectsUnknownSpec verifies unsupported cover specs produce a
|
||||
// validation error with available alternatives.
|
||||
func TestDriveCoverRejectsUnknownSpec(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveCover, []string{
|
||||
"+cover",
|
||||
"--file-token", "file_cover",
|
||||
"--spec", "poster",
|
||||
"--output", "cover",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid spec error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--spec" {
|
||||
t.Fatalf("param=%q, want --spec", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "available cover specs") {
|
||||
t.Fatalf("hint=%q, want available specs", validationErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverValidationErrors verifies cover flag validation rejects
|
||||
// incomplete and conflicting argument combinations.
|
||||
func TestDriveCoverValidationErrors(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveCover, []string{
|
||||
"+cover",
|
||||
"--file-token", "file_cover",
|
||||
"--spec", "default",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "--output is required when --spec is set") {
|
||||
t.Fatalf("unexpected missing output error: %v", err)
|
||||
}
|
||||
|
||||
err = mountAndRunDrive(t, DriveCover, []string{
|
||||
"+cover",
|
||||
"--file-token", "file_cover",
|
||||
"--list-only",
|
||||
"--spec", "default",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "--spec cannot be combined with --list-only") {
|
||||
t.Fatalf("unexpected list-only conflict: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewCommonHelpers exercises helper branches for extension
|
||||
// inference and fallback extension mapping.
|
||||
func TestDrivePreviewCommonHelpers(t *testing.T) {
|
||||
if got := drivePreviewFallbackExt("pdf"); got != ".pdf" {
|
||||
t.Fatalf("fallbackExt(pdf)=%q, want .pdf", got)
|
||||
}
|
||||
if got := drivePreviewFallbackExt("html"); got != ".html" {
|
||||
t.Fatalf("fallbackExt(html)=%q, want .html", got)
|
||||
}
|
||||
if got := drivePreviewFallbackExt("text"); got != ".txt" {
|
||||
t.Fatalf("fallbackExt(text)=%q, want .txt", got)
|
||||
}
|
||||
if got := drivePreviewFallbackExt("jpg"); got != ".jpg" {
|
||||
t.Fatalf("fallbackExt(jpg)=%q, want .jpg", got)
|
||||
}
|
||||
if got := drivePreviewFallbackExt("jpg_lin"); got != ".jpg" {
|
||||
t.Fatalf("fallbackExt(jpg_lin)=%q, want .jpg", got)
|
||||
}
|
||||
if got := drivePreviewFallbackExt("split_png"); got != ".png" {
|
||||
t.Fatalf("fallbackExt(split_png)=%q, want .png", got)
|
||||
}
|
||||
if got := drivePreviewFallbackExt("source"); got != "" {
|
||||
t.Fatalf("fallbackExt(source)=%q, want empty", got)
|
||||
}
|
||||
if got := drivePreviewFallbackExt("unknown"); got != "" {
|
||||
t.Fatalf("fallbackExt(unknown)=%q, want empty", got)
|
||||
}
|
||||
specs := availableDriveCoverSpecs()
|
||||
if len(specs) == 0 || specs[len(specs)-1] != "square" {
|
||||
t.Fatalf("availableDriveCoverSpecs()=%v, want square included", specs)
|
||||
}
|
||||
|
||||
header := http.Header{}
|
||||
header.Set("Content-Disposition", `attachment; filename="preview.pdf"`)
|
||||
resolution := drivePreviewExtensionByContentDisposition(header)
|
||||
if resolution == nil || resolution.Ext != ".pdf" {
|
||||
t.Fatalf("content disposition resolution=%+v, want .pdf", resolution)
|
||||
}
|
||||
header.Set("Content-Disposition", `attachment; filename="preview"`)
|
||||
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
|
||||
t.Fatalf("content disposition without ext should be nil: %+v", resolution)
|
||||
}
|
||||
|
||||
path, fallback := autoAppendDrivePreviewExtension("cover", http.Header{}, ".png")
|
||||
if path != "cover.png" || fallback == nil || fallback.Source != "fallback" {
|
||||
t.Fatalf("fallback append = (%q, %+v), want cover.png with fallback source", path, fallback)
|
||||
}
|
||||
path, fallback = autoAppendDrivePreviewExtension("cover.", http.Header{}, ".png")
|
||||
if path != "cover.png" || fallback == nil {
|
||||
t.Fatalf("trailing-dot append = (%q, %+v), want cover.png", path, fallback)
|
||||
}
|
||||
path, fallback = autoAppendDrivePreviewExtension("cover.pdf", http.Header{}, ".png")
|
||||
if path != "cover.pdf" || fallback != nil {
|
||||
t.Fatalf("explicit ext append = (%q, %+v), want unchanged path", path, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewMetadataAndPathResolution verifies metadata normalization
|
||||
// and output path resolution helpers across rename and overwrite flows.
|
||||
func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
candidate := drivePreviewCandidate{TypeCode: "999", StatusCode: "", Reason: ""}
|
||||
applyDrivePreviewTypeMeta(&candidate)
|
||||
applyDrivePreviewStatusMeta(&candidate)
|
||||
if candidate.Type != "unknown_999" {
|
||||
t.Fatalf("candidate.Type=%q, want unknown_999", candidate.Type)
|
||||
}
|
||||
if candidate.Reason != "Preview status is missing." {
|
||||
t.Fatalf("candidate.Reason=%q, want missing-status reason", candidate.Reason)
|
||||
}
|
||||
|
||||
ready := drivePreviewCandidate{TypeCode: "1", StatusCode: "0"}
|
||||
applyDrivePreviewTypeMeta(&ready)
|
||||
applyDrivePreviewStatusMeta(&ready)
|
||||
if ready.Type != "png" || !ready.Downloadable {
|
||||
t.Fatalf("ready candidate=%+v, want downloadable png", ready)
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "preview.pdf"), []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
|
||||
header := http.Header{}
|
||||
header.Set("Content-Type", "application/pdf")
|
||||
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDrivePreviewOutputPath(rename) error: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(renamed, "preview (1).pdf") {
|
||||
t.Fatalf("renamed=%q, want preview (1).pdf suffix", renamed)
|
||||
}
|
||||
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid if-exists error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--if-exists" {
|
||||
t.Fatalf("param=%q, want --if-exists", validationErr.Param)
|
||||
}
|
||||
|
||||
unusedPath, err := nextAvailableDrivePreviewPath(runtime.FileIO(), "fresh.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("nextAvailableDrivePreviewPath(unused) error: %v", err)
|
||||
}
|
||||
if unusedPath != "fresh.pdf" {
|
||||
t.Fatalf("unusedPath=%q, want fresh.pdf", unusedPath)
|
||||
}
|
||||
|
||||
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDrivePreviewOutputPath(overwrite) error: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(overwritten, "preview.pdf") {
|
||||
t.Fatalf("overwritten=%q, want preview.pdf suffix", overwritten)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
f.FileIOProvider = &statErrorProvider{inner: f.FileIOProvider, err: fs.ErrPermission}
|
||||
runtimeWithStatErr := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
|
||||
runtimeWithStatErr.Factory = f
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError)
|
||||
if err == nil {
|
||||
t.Fatal("expected stat permission error, got nil")
|
||||
}
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
if internalErr.Subtype != errs.SubtypeFileIO {
|
||||
t.Fatalf("Subtype=%q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
|
||||
}
|
||||
}
|
||||
|
||||
type drivePreviewTestStringer string
|
||||
|
||||
type statErrorProvider struct {
|
||||
inner fileio.Provider
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *statErrorProvider) Name() string { return "stat-error" }
|
||||
|
||||
func (p *statErrorProvider) ResolveFileIO(ctx context.Context) fileio.FileIO {
|
||||
return &statErrorFileIO{inner: p.inner.ResolveFileIO(ctx), err: p.err}
|
||||
}
|
||||
|
||||
type statErrorFileIO struct {
|
||||
inner fileio.FileIO
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *statErrorFileIO) Open(name string) (fileio.File, error) { return f.inner.Open(name) }
|
||||
|
||||
func (f *statErrorFileIO) Stat(string) (fileio.FileInfo, error) { return nil, f.err }
|
||||
|
||||
func (f *statErrorFileIO) ResolvePath(path string) (string, error) { return f.inner.ResolvePath(path) }
|
||||
|
||||
func (f *statErrorFileIO) Save(path string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) {
|
||||
return f.inner.Save(path, opts, body)
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer for scalar helper tests.
|
||||
func (s drivePreviewTestStringer) String() string { return string(s) }
|
||||
|
||||
// TestDrivePreviewScalarHelpers verifies scalar coercion helpers normalize
|
||||
// mixed API field types into strings.
|
||||
func TestDrivePreviewScalarHelpers(t *testing.T) {
|
||||
got := firstString(map[string]interface{}{
|
||||
"blank": " ",
|
||||
"number": float64(7),
|
||||
"flag": true,
|
||||
"named": drivePreviewTestStringer(" named "),
|
||||
"integer": int64(9),
|
||||
}, "blank", "named", "number")
|
||||
if got != "named" {
|
||||
t.Fatalf("firstString()=%q, want named", got)
|
||||
}
|
||||
|
||||
if got := firstString(map[string]interface{}{"flag": true}, "flag"); got != "true" {
|
||||
t.Fatalf("firstString(bool)=%q, want true", got)
|
||||
}
|
||||
if got := firstString(map[string]interface{}{"integer": int64(9)}, "integer"); got != "9" {
|
||||
t.Fatalf("firstString(int64)=%q, want 9", got)
|
||||
}
|
||||
|
||||
if got := versionString(" 42 "); got != "42" {
|
||||
t.Fatalf("versionString(string)=%q, want 42", got)
|
||||
}
|
||||
if got := versionString(float64(8)); got != "8" {
|
||||
t.Fatalf("versionString(float64)=%q, want 8", got)
|
||||
}
|
||||
if got := versionString(int64(11)); got != "11" {
|
||||
t.Fatalf("versionString(int64)=%q, want 11", got)
|
||||
}
|
||||
if got := versionString(struct{}{}); got != "" {
|
||||
t.Fatalf("versionString(struct)=%q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewAliasAndAvailabilityHelpers verifies alias lookup,
|
||||
// normalization, and available-type de-duplication helpers.
|
||||
func TestDrivePreviewAliasAndAvailabilityHelpers(t *testing.T) {
|
||||
if got := normalizeDrivePreviewRequest(" Source File "); got != "source_file" {
|
||||
t.Fatalf("normalizeDrivePreviewRequest()=%q, want source_file", got)
|
||||
}
|
||||
|
||||
aliases := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "1"})
|
||||
if len(aliases) == 0 || aliases[0] != "image" {
|
||||
t.Fatalf("previewAliasesForCandidate()=%v, want image alias", aliases)
|
||||
}
|
||||
if got := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "999"}); got != nil {
|
||||
t.Fatalf("previewAliasesForCandidate(unknown)=%v, want nil", got)
|
||||
}
|
||||
|
||||
types := availableDrivePreviewTypes([]drivePreviewCandidate{
|
||||
{Type: "pdf"},
|
||||
{Type: "pdf"},
|
||||
{Type: " jpg "},
|
||||
{Type: ""},
|
||||
})
|
||||
if len(types) != 2 || types[0] != "pdf" || types[1] != "jpg" {
|
||||
t.Fatalf("availableDrivePreviewTypes()=%v, want [pdf jpg]", types)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewUnavailableHintAndContentTypeFallback verifies unavailable
|
||||
// preview errors and content-type fallback extension inference.
|
||||
func TestDrivePreviewUnavailableHintAndContentTypeFallback(t *testing.T) {
|
||||
err := wrapDrivePreviewUnavailable("file_preview", "html", []drivePreviewCandidate{
|
||||
{Type: "pdf"},
|
||||
{Type: "jpg"},
|
||||
}, "")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "available preview types: pdf, jpg") {
|
||||
t.Fatalf("hint=%q, want available preview types", validationErr.Hint)
|
||||
}
|
||||
|
||||
err = wrapDrivePreviewUnavailable("file_preview", "html", nil, fmt.Sprintf("custom reason for %s", "html"))
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "--list-only") {
|
||||
t.Fatalf("hint=%q, want list-only guidance", validationErr.Hint)
|
||||
}
|
||||
|
||||
resolution := drivePreviewExtensionByContentType("text/plain; charset=utf-8")
|
||||
if resolution == nil || resolution.Ext != ".txt" {
|
||||
t.Fatalf("drivePreviewExtensionByContentType()=%+v, want .txt", resolution)
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,6 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveCreateFolder,
|
||||
DriveCreateShortcut,
|
||||
DriveDownload,
|
||||
DrivePreview,
|
||||
DriveCover,
|
||||
DriveAddComment,
|
||||
DriveExport,
|
||||
DriveExportDownload,
|
||||
|
||||
@@ -15,8 +15,6 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+create-folder",
|
||||
"+create-shortcut",
|
||||
"+download",
|
||||
"+preview",
|
||||
"+cover",
|
||||
"+version-history",
|
||||
"+version-get",
|
||||
"+version-revert",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -50,15 +51,11 @@ const maxBodyFileSize = 32 * 1024 * 1024 // 32 MB
|
||||
func validateBodyFileMutex(bodyFlag, bodyFile string, validatePath func(string) error) error {
|
||||
bodyEmpty := strings.TrimSpace(bodyFlag) == ""
|
||||
if !bodyEmpty && bodyFile != "" {
|
||||
return mailValidationError("--body and --body-file are mutually exclusive; pass exactly one").
|
||||
WithParams(
|
||||
mailInvalidParam("--body", "mutually exclusive with --body-file"),
|
||||
mailInvalidParam("--body-file", "mutually exclusive with --body"),
|
||||
)
|
||||
return output.ErrValidation("--body and --body-file are mutually exclusive; pass exactly one")
|
||||
}
|
||||
if bodyFile != "" {
|
||||
if err := validatePath(bodyFile); err != nil {
|
||||
return mailValidationParamError("--body-file", "--body-file: %v", err).WithCause(err)
|
||||
return output.ErrValidation("--body-file: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -82,7 +79,7 @@ func resolveBodyFromFlags(runtime *common.RuntimeContext) (string, error) {
|
||||
|
||||
func validateRequiredResolvedBody(body string, hasTemplate bool, message string) error {
|
||||
if !hasTemplate && strings.TrimSpace(body) == "" {
|
||||
return mailValidationError("%s", message)
|
||||
return output.ErrValidation(message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -98,15 +95,15 @@ func validateRequiredResolvedBody(body string, hasTemplate bool, message string)
|
||||
func readBodyFile(fio fileio.FileIO, path string) (string, error) {
|
||||
f, err := fio.Open(path)
|
||||
if err != nil {
|
||||
return "", mailValidationParamError("--body-file", "open --body-file %s: %v", path, err).WithCause(mailInputStatError(err))
|
||||
return "", output.ErrValidation("open --body-file %s: %v", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
buf, err := io.ReadAll(io.LimitReader(f, maxBodyFileSize+1))
|
||||
if err != nil {
|
||||
return "", mailValidationParamError("--body-file", "read --body-file %s: %v", path, err).WithCause(err)
|
||||
return "", output.ErrValidation("read --body-file %s: %v", path, err)
|
||||
}
|
||||
if len(buf) > maxBodyFileSize {
|
||||
return "", mailValidationParamError("--body-file", "--body-file: file exceeds %d MB limit", maxBodyFileSize/1024/1024)
|
||||
return "", output.ErrValidation("--body-file: file exceeds %d MB limit", maxBodyFileSize/1024/1024)
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func encodeTextCharset(body []byte, label string) ([]byte, error) {
|
||||
}
|
||||
enc, _ := htmlcharset.Lookup(label)
|
||||
if enc == nil {
|
||||
return nil, fmt.Errorf("unsupported charset %q", label) //nolint:forbidigo // intermediate draft charset error; mail command layer wraps into typed ValidationError.
|
||||
return nil, fmt.Errorf("unsupported charset %q", label)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
writer := transform.NewWriter(&buf, enc.NewEncoder())
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//nolint:forbidigo // intermediate draft large-attachment parser errors; mail command layer wraps into typed ValidationError.
|
||||
package draft
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//nolint:forbidigo // intermediate draft attachment limit errors; mail command layer wraps into typed ValidationError.
|
||||
package draft
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//nolint:forbidigo // intermediate draft patch model errors; mail command layer wraps into typed ValidationError.
|
||||
package draft
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//nolint:forbidigo // intermediate draft EML parser errors; mail command layer wraps into typed ValidationError.
|
||||
package draft
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//nolint:forbidigo // intermediate draft patch application errors; mail command layer wraps into typed ValidationError.
|
||||
package draft
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//nolint:forbidigo // intermediate draft calendar patch errors; mail command layer wraps into typed ValidationError.
|
||||
package draft
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//nolint:forbidigo // intermediate draft serializer errors; mail command layer wraps into typed ValidationError.
|
||||
package draft
|
||||
|
||||
import (
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
package draft
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -31,13 +31,13 @@ func mailboxPath(mailboxID string, segments ...string) string {
|
||||
// draft_id, the input draftID is echoed back so callers always have a
|
||||
// non-empty identifier to round-trip.
|
||||
func GetRaw(runtime *common.RuntimeContext, mailboxID, draftID string) (DraftRaw, error) {
|
||||
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "drafts", draftID), map[string]interface{}{"format": "raw"}, nil)
|
||||
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "drafts", draftID), map[string]interface{}{"format": "raw"}, nil)
|
||||
if err != nil {
|
||||
return DraftRaw{}, err
|
||||
}
|
||||
raw := extractRawEML(data)
|
||||
if raw == "" {
|
||||
return DraftRaw{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "API response missing draft raw EML; the backend returned an empty raw body for this draft")
|
||||
return DraftRaw{}, fmt.Errorf("API response missing draft raw EML; the backend returned an empty raw body for this draft")
|
||||
}
|
||||
gotDraftID := extractDraftID(data)
|
||||
if gotDraftID == "" {
|
||||
@@ -55,13 +55,13 @@ func GetRaw(runtime *common.RuntimeContext, mailboxID, draftID string) (DraftRaw
|
||||
// assembled the EML with emlbuilder; for high-level compose paths use the
|
||||
// MailDraftCreate shortcut instead.
|
||||
func CreateWithRaw(runtime *common.RuntimeContext, mailboxID, rawEML string) (DraftResult, error) {
|
||||
data, err := runtime.CallAPITyped("POST", mailboxPath(mailboxID, "drafts"), nil, map[string]interface{}{"raw": rawEML})
|
||||
data, err := runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts"), nil, map[string]interface{}{"raw": rawEML})
|
||||
if err != nil {
|
||||
return DraftResult{}, err
|
||||
}
|
||||
draftID := extractDraftID(data)
|
||||
if draftID == "" {
|
||||
return DraftResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "API response missing draft_id")
|
||||
return DraftResult{}, fmt.Errorf("API response missing draft_id")
|
||||
}
|
||||
return DraftResult{
|
||||
DraftID: draftID,
|
||||
@@ -76,7 +76,7 @@ func CreateWithRaw(runtime *common.RuntimeContext, mailboxID, rawEML string) (Dr
|
||||
// carries the (possibly re-issued) draft ID and the preview reference URL
|
||||
// when the backend provides one.
|
||||
func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML string) (DraftResult, error) {
|
||||
data, err := runtime.CallAPITyped("PUT", mailboxPath(mailboxID, "drafts", draftID), nil, map[string]interface{}{"raw": rawEML})
|
||||
data, err := runtime.CallAPI("PUT", mailboxPath(mailboxID, "drafts", draftID), nil, map[string]interface{}{"raw": rawEML})
|
||||
if err != nil {
|
||||
return DraftResult{}, err
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func Send(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) (
|
||||
if sendTime != "" {
|
||||
bodyParams = map[string]interface{}{"send_time": sendTime}
|
||||
}
|
||||
return runtime.CallAPITyped("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, bodyParams)
|
||||
return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, bodyParams)
|
||||
}
|
||||
|
||||
// extractDraftID returns the first non-empty draft identifier found in the
|
||||
|
||||
@@ -53,7 +53,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/mail/filecheck"
|
||||
)
|
||||
|
||||
@@ -62,12 +61,9 @@ const MaxEMLSize = 25 * 1024 * 1024 // 25 MB
|
||||
|
||||
// readFile reads the named file and returns its contents via FileIO.
|
||||
func readFile(fio fileio.FileIO, path string) ([]byte, error) {
|
||||
if _, err := validate.SafeInputPath(path); err != nil {
|
||||
return nil, fmt.Errorf("attachment %q: %w", path, err) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
|
||||
}
|
||||
f, err := fio.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("attachment %q: %w", path, err) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
|
||||
return nil, fmt.Errorf("attachment %q: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(f)
|
||||
@@ -137,10 +133,10 @@ func New() Builder {
|
||||
func validateHeaderValue(v string) error {
|
||||
for _, r := range v {
|
||||
if r != '\t' && (r < 0x20 || r == 0x7f) {
|
||||
return fmt.Errorf("emlbuilder: header value contains control character: %q", v) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
|
||||
return fmt.Errorf("emlbuilder: header value contains control character: %q", v)
|
||||
}
|
||||
if isHeaderDangerousUnicode(r) {
|
||||
return fmt.Errorf("emlbuilder: header value contains dangerous Unicode character: %q", v) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
|
||||
return fmt.Errorf("emlbuilder: header value contains dangerous Unicode character: %q", v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -169,11 +165,11 @@ func isHeaderDangerousUnicode(r rune) bool {
|
||||
// or non-printable ASCII characters, as required by RFC 5322 field-name syntax.
|
||||
func validateHeaderName(n string) error {
|
||||
if strings.ContainsAny(n, ":\r\n") {
|
||||
return fmt.Errorf("emlbuilder: header name contains ':', CR, or LF: %q", n) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
|
||||
return fmt.Errorf("emlbuilder: header name contains ':', CR, or LF: %q", n)
|
||||
}
|
||||
for _, r := range n {
|
||||
if r < 0x21 || r > 0x7e {
|
||||
return fmt.Errorf("emlbuilder: header name contains non-printable character: %q", n) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
|
||||
return fmt.Errorf("emlbuilder: header name contains non-printable character: %q", n)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -183,7 +179,7 @@ func validateHeaderName(n string) error {
|
||||
// escape the quoted-string encoding used by mail.Address.String() and inject headers.
|
||||
func validateDisplayName(name string) error {
|
||||
if strings.ContainsAny(name, "\r\n") {
|
||||
return fmt.Errorf("emlbuilder: display name contains CR or LF: %q", name) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
|
||||
return fmt.Errorf("emlbuilder: display name contains CR or LF: %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -193,7 +189,7 @@ func validateDisplayName(name string) error {
|
||||
func validateCID(cid string) error {
|
||||
for _, r := range cid {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return fmt.Errorf("emlbuilder: content ID contains control character: %q", cid) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
|
||||
return fmt.Errorf("emlbuilder: content ID contains control character: %q", cid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -676,10 +672,10 @@ func (b Builder) Build() ([]byte, error) {
|
||||
return nil, b.err
|
||||
}
|
||||
if b.from.Address == "" {
|
||||
return nil, fmt.Errorf("emlbuilder: From address is required") //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
|
||||
return nil, fmt.Errorf("emlbuilder: From address is required")
|
||||
}
|
||||
if !b.allowNoRecipients && len(b.to)+len(b.cc)+len(b.bcc) == 0 {
|
||||
return nil, fmt.Errorf("emlbuilder: at least one recipient (To/CC/BCC) is required") //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
|
||||
return nil, fmt.Errorf("emlbuilder: at least one recipient (To/CC/BCC) is required")
|
||||
}
|
||||
|
||||
date := b.date
|
||||
@@ -758,7 +754,7 @@ func (b Builder) Build() ([]byte, error) {
|
||||
|
||||
raw := buf.Bytes()
|
||||
if len(raw) > MaxEMLSize {
|
||||
return nil, fmt.Errorf("emlbuilder: EML size %.1f MB exceeds the %.0f MB limit", //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
|
||||
return nil, fmt.Errorf("emlbuilder: EML size %.1f MB exceeds the %.0f MB limit",
|
||||
float64(len(raw))/1024/1024, float64(MaxEMLSize)/1024/1024)
|
||||
}
|
||||
return raw, nil
|
||||
|
||||
@@ -124,7 +124,7 @@ func CheckBlockedExtension(filename string) error {
|
||||
return nil
|
||||
}
|
||||
if _, ok := blockedExtensions[ext]; ok {
|
||||
return fmt.Errorf("file extension %q is not allowed as a mail attachment", "."+ext) //nolint:forbidigo // intermediate mail file-format check; mail command layer wraps into typed ValidationError.
|
||||
return fmt.Errorf("file extension %q is not allowed as a mail attachment", "."+ext)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -156,7 +156,7 @@ var allowedInlineMIMETypes = map[string]struct{}{
|
||||
func CheckInlineImageFormat(filename string, content []byte) (string, error) {
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
|
||||
if _, ok := allowedInlineExtensions[ext]; !ok {
|
||||
return "", fmt.Errorf("inline image extension %q is not allowed; supported formats: jpg, jpeg, png, gif, webp", ext) //nolint:forbidigo // intermediate mail file-format check; mail command layer wraps into typed ValidationError.
|
||||
return "", fmt.Errorf("inline image extension %q is not allowed; supported formats: jpg, jpeg, png, gif, webp", ext)
|
||||
}
|
||||
detected := http.DetectContentType(content)
|
||||
// DetectContentType may return params (e.g. "text/plain; charset=utf-8"),
|
||||
@@ -165,7 +165,7 @@ func CheckInlineImageFormat(filename string, content []byte) (string, error) {
|
||||
detected = strings.TrimSpace(detected[:i])
|
||||
}
|
||||
if _, ok := allowedInlineMIMETypes[detected]; !ok {
|
||||
return "", fmt.Errorf("inline image content type %q does not match an allowed image format; supported: image/jpeg, image/png, image/gif, image/webp", detected) //nolint:forbidigo // intermediate mail file-format check; mail command layer wraps into typed ValidationError.
|
||||
return "", fmt.Errorf("inline image content type %q does not match an allowed image format; supported: image/jpeg, image/png, image/gif, image/webp", detected)
|
||||
}
|
||||
return detected, nil
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// flagName is a package-private snapshot of a pflag.Flag's identity.
|
||||
@@ -21,7 +21,8 @@ type flagName struct {
|
||||
}
|
||||
|
||||
// Candidate is a single suggested flag returned to the user when an
|
||||
// unknown flag is detected.
|
||||
// unknown flag is detected. It is serialised into the ErrorEnvelope's
|
||||
// error.detail.candidates[] array.
|
||||
type Candidate struct {
|
||||
// Flag is the long-form spelling of the suggested flag, e.g. "--to".
|
||||
Flag string `json:"flag"`
|
||||
@@ -55,9 +56,9 @@ func InstallOnMail(svc *cobra.Command) {
|
||||
svc.SetFlagErrorFunc(flagSuggestErrorFunc)
|
||||
}
|
||||
|
||||
// flagSuggestErrorFunc converts pflag's unknown-flag errors into a typed
|
||||
// validation error carrying candidate suggestions. Any other error is passed
|
||||
// through unchanged so cobra's existing handling kicks in.
|
||||
// flagSuggestErrorFunc converts pflag's unknown-flag errors into a
|
||||
// structured *output.ExitError carrying candidate suggestions. Any other
|
||||
// error is passed through unchanged so cobra's existing handling kicks in.
|
||||
func flagSuggestErrorFunc(c *cobra.Command, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -82,21 +83,22 @@ func flagSuggestErrorFunc(c *cobra.Command, err error) error {
|
||||
matches = []Candidate{}
|
||||
}
|
||||
hint := buildHint(c, matches)
|
||||
params := []errs.InvalidParam{{
|
||||
Name: rawUnknownToken(token, isShorthand),
|
||||
Reason: "unknown flag",
|
||||
}}
|
||||
for _, match := range matches {
|
||||
reason := fmt.Sprintf("candidate (%s, distance=%d)", match.Reason, match.Distance)
|
||||
if match.Shorthand != "" {
|
||||
reason += fmt.Sprintf(", shorthand=-%s", match.Shorthand)
|
||||
}
|
||||
params = append(params, errs.InvalidParam{Name: match.Flag, Reason: reason})
|
||||
detail := map[string]any{
|
||||
"unknown": rawUnknownToken(token, isShorthand),
|
||||
"command_path": c.CommandPath(),
|
||||
"candidates": matches,
|
||||
}
|
||||
// Code is ExitAPI (=1), matching cobra's default unknown-flag exit
|
||||
// code. The structured type discrimination lives in error.type.
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: err.Error(),
|
||||
Hint: hint,
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, err.Error()).
|
||||
WithHint("%s", hint).
|
||||
WithParam(rawUnknownToken(token, isShorthand)).
|
||||
WithParams(params...)
|
||||
}
|
||||
|
||||
// parseUnknownToken extracts the offending flag name from a pflag error
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// --- suggest (long-flag) ---
|
||||
@@ -175,41 +175,35 @@ func newFakeMailCmd() *cobra.Command {
|
||||
return c
|
||||
}
|
||||
|
||||
func requireFlagSuggestValidation(t *testing.T, got error) *errs.ValidationError {
|
||||
t.Helper()
|
||||
var validationErr *errs.ValidationError
|
||||
require.True(t, errors.As(got, &validationErr), "expected *errs.ValidationError, got %T", got)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
require.True(t, ok, "expected typed Problem")
|
||||
assert.Equal(t, errs.CategoryValidation, p.Category)
|
||||
assert.Equal(t, errs.SubtypeInvalidArgument, p.Subtype)
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func paramReason(params []errs.InvalidParam, name string) (string, bool) {
|
||||
for _, p := range params {
|
||||
if p.Name == name {
|
||||
return p.Reason, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_LongUnknown_ReturnsTypedValidation(t *testing.T) {
|
||||
func TestFlagSuggestErrorFunc_LongUnknown_ReturnsExitError(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
|
||||
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.Equal(t, "unknown flag: --tos", validationErr.Message)
|
||||
assert.Equal(t, "--tos", validationErr.Param)
|
||||
assert.Contains(t, validationErr.Hint, "--to")
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr), "expected *output.ExitError, got %T", got)
|
||||
require.NotNil(t, exitErr.Detail)
|
||||
assert.Equal(t, "unknown_flag", exitErr.Detail.Type)
|
||||
assert.Equal(t, "unknown flag: --tos", exitErr.Detail.Message)
|
||||
assert.Contains(t, exitErr.Detail.Hint, "--to")
|
||||
|
||||
reason, ok := paramReason(validationErr.Params, "--tos")
|
||||
require.True(t, ok, "unknown flag should be included in params")
|
||||
assert.Equal(t, "unknown flag", reason)
|
||||
reason, ok = paramReason(validationErr.Params, "--to")
|
||||
require.True(t, ok, "expected --to in candidate params")
|
||||
assert.Contains(t, reason, "candidate (prefix")
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
require.True(t, ok, "Detail.Detail should be map[string]any")
|
||||
assert.Equal(t, "--tos", detail["unknown"])
|
||||
assert.Equal(t, cmd.CommandPath(), detail["command_path"])
|
||||
|
||||
cands, ok := detail["candidates"].([]Candidate)
|
||||
require.True(t, ok, "candidates should be []Candidate")
|
||||
require.NotEmpty(t, cands)
|
||||
|
||||
var foundTo bool
|
||||
for _, c := range cands {
|
||||
if c.Flag == "--to" {
|
||||
foundTo = true
|
||||
assert.Equal(t, "prefix", c.Reason)
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundTo, "expected --to in candidates")
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_NotUnknownFlag_PassesThrough(t *testing.T) {
|
||||
@@ -220,13 +214,14 @@ func TestFlagSuggestErrorFunc_NotUnknownFlag_PassesThrough(t *testing.T) {
|
||||
assert.Same(t, in, got, "non-unknown-flag errors must be returned unchanged")
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_TypedCategoryAndSubtype(t *testing.T) {
|
||||
func TestFlagSuggestErrorFunc_ExitCodeIsOne(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
|
||||
p, ok := errs.ProblemOf(got)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errs.CategoryValidation, p.Category)
|
||||
assert.Equal(t, errs.SubtypeInvalidArgument, p.Subtype)
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr))
|
||||
// Hard contract — both compile-time and runtime guards:
|
||||
assert.Equal(t, output.ExitAPI, exitErr.Code, "unknown_flag must use ExitAPI, not ExitValidation")
|
||||
assert.Equal(t, 1, output.ExitAPI, "ExitAPI constant must remain 1")
|
||||
}
|
||||
|
||||
// --- edge-case coverage ---
|
||||
@@ -241,8 +236,9 @@ func TestInstallOnMail_InstallsHook(t *testing.T) {
|
||||
InstallOnMail(c)
|
||||
require.NotNil(t, c.FlagErrorFunc())
|
||||
got := c.FlagErrorFunc()(c, errors.New("unknown flag: --tos"))
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.Equal(t, "--tos", validationErr.Param)
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr), "installed hook must produce *output.ExitError")
|
||||
assert.Equal(t, "unknown_flag", exitErr.Detail.Type)
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_NilError(t *testing.T) {
|
||||
@@ -253,47 +249,50 @@ func TestFlagSuggestErrorFunc_NilError(t *testing.T) {
|
||||
func TestFlagSuggestErrorFunc_LongUnknown_StripsValueTail(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos=alice@example.com"))
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.Equal(t, "--tos", validationErr.Param, "value tail must be stripped before echoing")
|
||||
reason, ok := paramReason(validationErr.Params, "--tos")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "unknown flag", reason)
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr))
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
assert.Equal(t, "--tos", detail["unknown"], "value tail must be stripped before echoing")
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_ShorthandUnknown(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown shorthand flag: 'b' in -bXY"))
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.Equal(t, "-b", validationErr.Param)
|
||||
reason, ok := paramReason(validationErr.Params, "-b")
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr))
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
assert.Equal(t, "-b", detail["unknown"])
|
||||
cands, ok := detail["candidates"].([]Candidate)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "unknown flag", reason)
|
||||
// newFakeMailCmd has --body/-b; exact shorthand hit expected.
|
||||
reason, ok = paramReason(validationErr.Params, "--body")
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, reason, "candidate (prefix")
|
||||
assert.Contains(t, reason, "shorthand=-b")
|
||||
require.NotEmpty(t, cands)
|
||||
assert.Equal(t, "--body", cands[0].Flag)
|
||||
assert.Equal(t, "b", cands[0].Shorthand)
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_ParamsAlwaysPresent(t *testing.T) {
|
||||
func TestFlagSuggestErrorFunc_CandidatesAlwaysArray(t *testing.T) {
|
||||
// A cobra command with no flags forces collectFlags → empty names →
|
||||
// suggest → nil. The typed validation error must still expose the unknown
|
||||
// flag in Params so downstream parsers have a stable structured field.
|
||||
// suggest → nil. The envelope must still expose candidates as a
|
||||
// non-nil []Candidate so the JSON wire shape is "candidates: []"
|
||||
// rather than "candidates: null".
|
||||
bare := &cobra.Command{Use: "mail"}
|
||||
got := flagSuggestErrorFunc(bare, errors.New("unknown flag: --bogus"))
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.NotNil(t, validationErr.Params)
|
||||
require.Len(t, validationErr.Params, 1)
|
||||
assert.Equal(t, "--bogus", validationErr.Params[0].Name)
|
||||
assert.Equal(t, "unknown flag", validationErr.Params[0].Reason)
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr))
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
cands, ok := detail["candidates"].([]Candidate)
|
||||
require.True(t, ok, "candidates must be []Candidate even when empty")
|
||||
assert.NotNil(t, cands, "candidates must be non-nil empty slice, not nil")
|
||||
assert.Empty(t, cands)
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_NoCandidatesUsesHelpHint(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
// Token with no plausible neighbor in {to, cc, subject, body}.
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --zzzzzzz"))
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.Contains(t, validationErr.Hint, "--help")
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr))
|
||||
assert.Contains(t, exitErr.Detail.Hint, "--help")
|
||||
}
|
||||
|
||||
func TestParseUnknownToken_EmptyAndMalformed(t *testing.T) {
|
||||
|
||||
@@ -6,7 +6,6 @@ package mail
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
@@ -22,6 +21,7 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
@@ -111,7 +111,7 @@ func requireSenderForRequestReceipt(runtime *common.RuntimeContext, senderEmail
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(senderEmail) == "" {
|
||||
return mailValidationError(
|
||||
return output.ErrValidation(
|
||||
"--request-receipt requires a resolvable sender address; specify a sender address where supported, or ensure the draft has a From address")
|
||||
}
|
||||
return nil
|
||||
@@ -130,10 +130,10 @@ func requireSenderForRequestReceipt(runtime *common.RuntimeContext, senderEmail
|
||||
func validateHeaderAddress(addr string) error {
|
||||
for _, r := range addr {
|
||||
if r != '\t' && (r < 0x20 || r == 0x7f) {
|
||||
return mailValidationError("address contains control character: %q", addr)
|
||||
return fmt.Errorf("address contains control character: %q", addr)
|
||||
}
|
||||
if common.IsDangerousUnicode(r) {
|
||||
return mailValidationError("address contains dangerous Unicode code point: %q", addr)
|
||||
return fmt.Errorf("address contains dangerous Unicode code point: %q", addr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -324,7 +324,7 @@ func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string)
|
||||
if mailboxID == "" {
|
||||
mailboxID = "me"
|
||||
}
|
||||
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "profile"), nil, nil)
|
||||
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "profile"), nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -336,7 +336,7 @@ func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string)
|
||||
return email, nil
|
||||
}
|
||||
}
|
||||
return "", mailInvalidResponseError("profile API returned no primary_email_address")
|
||||
return "", fmt.Errorf("profile API returned no primary_email_address")
|
||||
}
|
||||
|
||||
// extractPrimaryEmail returns the user's primary email address from a
|
||||
@@ -503,14 +503,12 @@ func resolveFolderID(runtime *common.RuntimeContext, mailboxID, input string) (s
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolveByName("folder", value, mailboxID, folders,
|
||||
func(item folderInfo) string { return item.ID },
|
||||
func(item folderInfo) string { return item.Name },
|
||||
)
|
||||
return resolveByID("folder", value, mailboxID, folders, func(item folderInfo) string { return item.ID })
|
||||
}
|
||||
|
||||
// resolveFolderName accepts either a folder ID or a folder name and returns
|
||||
// the canonical folder ID.
|
||||
// the human-readable folder name. Used for output rendering where the user
|
||||
// wants to see the name they originally chose, not the opaque ID.
|
||||
func resolveFolderName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) {
|
||||
value := strings.TrimSpace(input)
|
||||
if value == "" {
|
||||
@@ -544,14 +542,11 @@ func resolveLabelID(runtime *common.RuntimeContext, mailboxID, input string) (st
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolveByName("label", value, mailboxID, labels,
|
||||
func(item labelInfo) string { return item.ID },
|
||||
func(item labelInfo) string { return item.Name },
|
||||
)
|
||||
return resolveByID("label", value, mailboxID, labels, func(item labelInfo) string { return item.ID })
|
||||
}
|
||||
|
||||
// resolveLabelName accepts either a label ID or a label name and returns
|
||||
// the canonical label ID (mirror of resolveFolderName for labels).
|
||||
// the human-readable label name (mirror of resolveFolderName for labels).
|
||||
func resolveLabelName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) {
|
||||
value := strings.TrimSpace(input)
|
||||
if value == "" {
|
||||
@@ -851,11 +846,9 @@ func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]fol
|
||||
if err := validateFolderReadScope(runtime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "folders"), nil, nil)
|
||||
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "folders"), nil, nil)
|
||||
if err != nil {
|
||||
return nil, mailAppendProblemHint(
|
||||
mailDecorateProblemMessage(err, "unable to resolve --folder: failed to list folders"),
|
||||
resolveLookupHint("folder", mailboxID))
|
||||
return nil, output.ErrValidation("unable to resolve --folder: failed to list folders (%v). %s", err, resolveLookupHint("folder", mailboxID))
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
folders := make([]folderInfo, 0, len(items))
|
||||
@@ -878,11 +871,9 @@ func listMailboxLabels(runtime *common.RuntimeContext, mailboxID string) ([]labe
|
||||
if err := validateLabelReadScope(runtime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "labels"), nil, nil)
|
||||
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "labels"), nil, nil)
|
||||
if err != nil {
|
||||
return nil, mailAppendProblemHint(
|
||||
mailDecorateProblemMessage(err, "unable to resolve --label: failed to list labels"),
|
||||
resolveLookupHint("label", mailboxID))
|
||||
return nil, output.ErrValidation("unable to resolve --label: failed to list labels (%v). %s", err, resolveLookupHint("label", mailboxID))
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
labels := make([]labelInfo, 0, len(items))
|
||||
@@ -900,9 +891,26 @@ func listMailboxLabels(runtime *common.RuntimeContext, mailboxID string) ([]labe
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
// resolveByName looks up input as an exact ID first, then as a name, and
|
||||
// returns the matching ID. Errors out on duplicate names so callers get a clear
|
||||
// "ambiguous name" signal rather than silently picking one match.
|
||||
// resolveByID looks up input as an ID in items, returning input itself when
|
||||
// found. kind ("folder" / "label") and mailboxID are used to construct the
|
||||
// not-found hint. Generic over T so the same logic serves both folder and
|
||||
// label tables.
|
||||
func resolveByID[T any](kind, input, mailboxID string, items []T, idFn func(T) string) (string, error) {
|
||||
value := strings.TrimSpace(input)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
for _, item := range items {
|
||||
if id := idFn(item); id != "" && id == value {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
|
||||
}
|
||||
|
||||
// resolveByName looks up input as a name in items and returns the matching
|
||||
// ID. Errors out on duplicates so callers get a clear "ambiguous name"
|
||||
// signal rather than silently picking one match.
|
||||
func resolveByName[T any](kind, input, mailboxID string, items []T, idFn func(T) string, nameFn func(T) string) (string, error) {
|
||||
value := strings.TrimSpace(input)
|
||||
if value == "" {
|
||||
@@ -911,7 +919,7 @@ func resolveByName[T any](kind, input, mailboxID string, items []T, idFn func(T)
|
||||
|
||||
for _, item := range items {
|
||||
if id := idFn(item); id != "" && id == value {
|
||||
return id, nil
|
||||
return "", output.ErrValidation("%s %q looks like an ID; please use %s_id", kind, value, kind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -935,9 +943,9 @@ func resolveByName[T any](kind, input, mailboxID string, items []T, idFn func(T)
|
||||
return matches[0], nil
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return "", mailValidationError("%s name %q matches multiple IDs (%s); please use an ID", kind, value, strings.Join(matches, ","))
|
||||
return "", output.ErrValidation("%s name %q matches multiple IDs (%s); please use an ID", kind, value, strings.Join(matches, ","))
|
||||
}
|
||||
return "", mailValidationError("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
|
||||
return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
|
||||
}
|
||||
|
||||
// resolveNameValueByID is the inverse of resolveByID: it looks up an ID
|
||||
@@ -951,18 +959,18 @@ func resolveNameValueByID[T any](kind, input, mailboxID string, items []T, idFn
|
||||
if id := idFn(item); id != "" && id == value {
|
||||
name := strings.TrimSpace(nameFn(item))
|
||||
if name == "" {
|
||||
return "", mailValidationError("%s %q has empty name; cannot use it with query filters", kind, value)
|
||||
return "", output.ErrValidation("%s %q has empty name; cannot use it with query filters", kind, value)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
return "", mailValidationError("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
|
||||
return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
|
||||
}
|
||||
|
||||
// resolveNameValueByNameAllowDuplicates looks up input as an exact ID first,
|
||||
// then as a name, and returns the matching name. Duplicate names are tolerated
|
||||
// by returning the first match. Used in query-style contexts where ambiguity is
|
||||
// acceptable because the API itself disambiguates server-side.
|
||||
// resolveNameValueByNameAllowDuplicates is like resolveByName but tolerates
|
||||
// duplicate names — returning the first match. Used in query-style contexts
|
||||
// where ambiguity is acceptable because the API itself disambiguates server-
|
||||
// side.
|
||||
func resolveNameValueByNameAllowDuplicates[T any](kind, input, mailboxID string, items []T, idFn func(T) string, nameFn func(T) string) (string, error) {
|
||||
value := strings.TrimSpace(input)
|
||||
if value == "" {
|
||||
@@ -970,11 +978,7 @@ func resolveNameValueByNameAllowDuplicates[T any](kind, input, mailboxID string,
|
||||
}
|
||||
for _, item := range items {
|
||||
if id := idFn(item); id != "" && id == value {
|
||||
name := strings.TrimSpace(nameFn(item))
|
||||
if name == "" {
|
||||
return "", mailValidationError("%s %q has empty name; cannot use it with query filters", kind, value)
|
||||
}
|
||||
return name, nil
|
||||
return "", output.ErrValidation("%s %q looks like an ID; please use %s_id", kind, value, kind)
|
||||
}
|
||||
}
|
||||
lower := strings.ToLower(value)
|
||||
@@ -985,7 +989,7 @@ func resolveNameValueByNameAllowDuplicates[T any](kind, input, mailboxID string,
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
return "", mailValidationError("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
|
||||
return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
|
||||
}
|
||||
|
||||
// resolveLookupHint returns the CLI command a user should run to list
|
||||
@@ -1011,13 +1015,13 @@ func resolveLookupHint(kind, mailboxID string) string {
|
||||
// html=false -> format=plain_text_full (server omits body_html)
|
||||
func fetchFullMessage(runtime *common.RuntimeContext, mailboxID, messageID string, html bool) (map[string]interface{}, error) {
|
||||
params := map[string]interface{}{"format": messageGetFormat(html)}
|
||||
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "messages", messageID), params, nil)
|
||||
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "messages", messageID), params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg, _ := data["message"].(map[string]interface{})
|
||||
if msg == nil {
|
||||
return nil, mailInvalidResponseError("API response missing message field")
|
||||
return nil, fmt.Errorf("API response missing message field")
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
@@ -1035,7 +1039,7 @@ func fetchFullMessages(runtime *common.RuntimeContext, mailboxID string, message
|
||||
if end > len(messageIDs) {
|
||||
end = len(messageIDs)
|
||||
}
|
||||
data, err := runtime.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_get"), nil, map[string]interface{}{
|
||||
data, err := runtime.CallAPI("POST", mailboxPath(mailboxID, "messages", "batch_get"), nil, map[string]interface{}{
|
||||
"format": messageGetFormat(html),
|
||||
"message_ids": messageIDs[start:end],
|
||||
})
|
||||
@@ -1228,7 +1232,7 @@ type calendarEventOutput struct {
|
||||
// It never returns an error: failed batches/IDs are converted to structured warnings so caller can continue.
|
||||
func fetchAttachmentURLs(runtime *common.RuntimeContext, mailboxID, messageID string, ids []string) (map[string]string, []warningEntry) {
|
||||
callAPI := func(url string) (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped("GET", url, nil, nil)
|
||||
return runtime.CallAPI("GET", url, nil, nil)
|
||||
}
|
||||
emitWarning := func(w warningEntry) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: code=%s message_id=%s attachment_id=%s retryable=%t detail=%s\n", w.Code, w.MessageID, w.AttachmentID, w.Retryable, w.Detail)
|
||||
@@ -1664,7 +1668,7 @@ func validateForwardAttachmentURLs(src composeSourceMessage) error {
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return mailInvalidResponseError("failed to fetch download URLs for: %s", strings.Join(missing, ", "))
|
||||
return fmt.Errorf("failed to fetch download URLs for: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1679,7 +1683,7 @@ func validateInlineImageURLs(src composeSourceMessage) error {
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return mailInvalidResponseError("failed to fetch download URLs for: %s", strings.Join(missing, ", "))
|
||||
return fmt.Errorf("failed to fetch download URLs for: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1782,51 +1786,41 @@ func toInlineSourceParts(out normalizedMessageForCompose) []inlineSourcePart {
|
||||
func downloadAttachmentContent(runtime *common.RuntimeContext, downloadURL string) ([]byte, error) {
|
||||
u, err := url.Parse(downloadURL)
|
||||
if err != nil {
|
||||
return nil, mailInvalidResponseError("invalid attachment download URL: %v", err).WithCause(err)
|
||||
return nil, fmt.Errorf("invalid attachment download URL: %w", err)
|
||||
}
|
||||
if u.Scheme != "https" {
|
||||
return nil, mailInvalidResponseError("attachment download URL must use https (got %q)", u.Scheme)
|
||||
return nil, fmt.Errorf("attachment download URL must use https (got %q)", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, mailInvalidResponseError("attachment download URL has no host")
|
||||
return nil, fmt.Errorf("attachment download URL has no host")
|
||||
}
|
||||
|
||||
httpClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "failed to get HTTP client: %v", err).WithCause(err)
|
||||
return nil, fmt.Errorf("failed to get HTTP client: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(runtime.Ctx(), http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "failed to build attachment download request: %v", err).WithCause(err)
|
||||
return nil, fmt.Errorf("failed to build attachment download request: %w", err)
|
||||
}
|
||||
// Do NOT send Authorization: the download_url is a pre-signed URL with an
|
||||
// authcode embedded in the query string. Attaching the Bearer token would
|
||||
// leak it to whatever host the URL points at (SSRF / token exfiltration).
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to download attachment: %v", err).WithCause(err)
|
||||
return nil, fmt.Errorf("failed to download attachment: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "failed to download attachment: HTTP %d", resp.StatusCode).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return nil, errs.NewAPIError(subtype, "failed to download attachment: HTTP %d", resp.StatusCode).WithCode(resp.StatusCode)
|
||||
return nil, fmt.Errorf("failed to download attachment: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
limitedReader := io.LimitReader(resp.Body, int64(MaxAttachmentDownloadBytes)+1)
|
||||
data, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to read attachment content: %v", err).WithCause(err)
|
||||
return nil, fmt.Errorf("failed to read attachment content: %w", err)
|
||||
}
|
||||
if len(data) > MaxAttachmentDownloadBytes {
|
||||
return nil, mailFailedPreconditionError("attachment download exceeds %d MB size limit", MaxAttachmentDownloadBytes/1024/1024).
|
||||
WithHint("download or forward this large attachment outside the inline/small-attachment path")
|
||||
return nil, fmt.Errorf("attachment download exceeds %d MB size limit", MaxAttachmentDownloadBytes/1024/1024)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -2068,7 +2062,7 @@ func parsePriority(value string) (string, error) {
|
||||
case "low":
|
||||
return "5", nil
|
||||
default:
|
||||
return "", mailValidationParamError("--priority", "invalid --priority value %q: expected high, normal, or low", value)
|
||||
return "", fmt.Errorf("invalid --priority value %q: expected high, normal, or low", value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2238,7 +2232,7 @@ func validateInlineCIDs(html string, userCIDs, extraCIDs []string) error {
|
||||
if len(userCIDs) > 0 {
|
||||
orphaned := draftpkg.FindOrphanedCIDs(html, userCIDs)
|
||||
if len(orphaned) > 0 {
|
||||
return mailValidationParamError("--inline", "inline images with cids %v are not referenced by any <img src=\"cid:...\"> in the HTML body and will appear as unexpected attachments; remove unused --inline entries or add matching <img> tags", orphaned)
|
||||
return fmt.Errorf("inline images with cids %v are not referenced by any <img src=\"cid:...\"> in the HTML body and will appear as unexpected attachments; remove unused --inline entries or add matching <img> tags", orphaned)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -2257,7 +2251,7 @@ func addInlineImagesToBuilder(runtime *common.RuntimeContext, bld emlbuilder.Bui
|
||||
for _, img := range images {
|
||||
content, err := downloadAttachmentContent(runtime, img.DownloadURL)
|
||||
if err != nil {
|
||||
return bld, nil, 0, mailDecorateProblemMessage(err, "failed to download inline resource %s", img.Filename)
|
||||
return bld, nil, 0, fmt.Errorf("failed to download inline resource %s: %w", img.Filename, err)
|
||||
}
|
||||
cid := normalizeInlineCID(img.CID)
|
||||
if cid == "" {
|
||||
@@ -2290,14 +2284,14 @@ func parseInlineSpecs(raw string) ([]InlineSpec, error) {
|
||||
}
|
||||
var specs []InlineSpec
|
||||
if err := json.Unmarshal([]byte(raw), &specs); err != nil {
|
||||
return nil, mailValidationParamError("--inline", "--inline must be a JSON array, e.g. '[{\"cid\":\"a1b2c3d4e5f6a7b8c9d0\",\"file_path\":\"./banner.png\"}]': %v", err).WithCause(err)
|
||||
return nil, fmt.Errorf("--inline must be a JSON array, e.g. '[{\"cid\":\"a1b2c3d4e5f6a7b8c9d0\",\"file_path\":\"./banner.png\"}]': %w", err)
|
||||
}
|
||||
for i, s := range specs {
|
||||
if strings.TrimSpace(s.CID) == "" {
|
||||
return nil, mailValidationParamError("--inline", "--inline entry %d: \"cid\" must not be empty", i)
|
||||
return nil, fmt.Errorf("--inline entry %d: \"cid\" must not be empty", i)
|
||||
}
|
||||
if strings.TrimSpace(s.FilePath) == "" {
|
||||
return nil, mailValidationParamError("--inline", "--inline entry %d: \"file_path\" must not be empty", i)
|
||||
return nil, fmt.Errorf("--inline entry %d: \"file_path\" must not be empty", i)
|
||||
}
|
||||
}
|
||||
return specs, nil
|
||||
@@ -2324,11 +2318,7 @@ func validateEventSendTimeExclusion(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
|
||||
if runtime.Str(f) != "" {
|
||||
return mailValidationError("--send-time and --event-* are mutually exclusive: a calendar invitation must be sent immediately so recipients can respond before the event").
|
||||
WithParams(
|
||||
mailInvalidParam("--send-time", "mutually exclusive with --event-*"),
|
||||
mailInvalidParam("--event-*", "mutually exclusive with --send-time"),
|
||||
)
|
||||
return common.FlagErrorf("--send-time and --event-* are mutually exclusive: a calendar invitation must be sent immediately so recipients can respond before the event")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -2342,15 +2332,15 @@ func validateSendTime(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
if !runtime.Bool("confirm-send") {
|
||||
return mailValidationParamError("--send-time", "--send-time requires --confirm-send to be set")
|
||||
return output.ErrValidation("--send-time requires --confirm-send to be set")
|
||||
}
|
||||
ts, err := strconv.ParseInt(sendTime, 10, 64)
|
||||
if err != nil {
|
||||
return mailValidationParamError("--send-time", "--send-time must be a valid Unix timestamp in seconds, got %q", sendTime).WithCause(err)
|
||||
return output.ErrValidation("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime)
|
||||
}
|
||||
minTime := time.Now().Unix() + 5*60
|
||||
if ts < minTime {
|
||||
return mailValidationParamError("--send-time", "--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
|
||||
return output.ErrValidation("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2439,12 +2429,7 @@ func validateLabelReadScope(runtime *common.RuntimeContext) error {
|
||||
// all three (to/cc/bcc) are empty or whitespace-only.
|
||||
func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error {
|
||||
if strings.TrimSpace(to) == "" && strings.TrimSpace(cc) == "" && strings.TrimSpace(bcc) == "" {
|
||||
return mailValidationError("at least one recipient (--to, --cc, or --bcc) is required").
|
||||
WithParams(
|
||||
mailInvalidParam("--to", "at least one recipient is required"),
|
||||
mailInvalidParam("--cc", "at least one recipient is required"),
|
||||
mailInvalidParam("--bcc", "at least one recipient is required"),
|
||||
)
|
||||
return fmt.Errorf("at least one recipient (--to, --cc, or --bcc) is required")
|
||||
}
|
||||
return validateRecipientCount(to, cc, bcc)
|
||||
}
|
||||
@@ -2454,12 +2439,7 @@ func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error {
|
||||
func validateRecipientCount(to, cc, bcc string) error {
|
||||
count := len(ParseMailboxList(to)) + len(ParseMailboxList(cc)) + len(ParseMailboxList(bcc))
|
||||
if count > MaxRecipientCount {
|
||||
return mailValidationError("total recipient count %d exceeds the limit of %d (To + CC + BCC combined)", count, MaxRecipientCount).
|
||||
WithParams(
|
||||
mailInvalidParam("--to", "recipient count contributes to combined limit"),
|
||||
mailInvalidParam("--cc", "recipient count contributes to combined limit"),
|
||||
mailInvalidParam("--bcc", "recipient count contributes to combined limit"),
|
||||
)
|
||||
return fmt.Errorf("total recipient count %d exceeds the limit of %d (To + CC + BCC combined)", count, MaxRecipientCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2471,14 +2451,10 @@ func validateRecipientCount(to, cc, bcc string) error {
|
||||
func validateComposeInlineAndAttachments(fio fileio.FileIO, attachFlag, inlineFlag string, plainText bool, body string) error {
|
||||
if strings.TrimSpace(inlineFlag) != "" {
|
||||
if plainText {
|
||||
return mailValidationError("--inline is not supported with --plain-text (inline images require HTML body)").
|
||||
WithParams(
|
||||
mailInvalidParam("--inline", "requires HTML body"),
|
||||
mailInvalidParam("--plain-text", "mutually exclusive with --inline"),
|
||||
)
|
||||
return output.ErrValidation("--inline is not supported with --plain-text (inline images require HTML body)")
|
||||
}
|
||||
if body != "" && !bodyIsHTML(body) {
|
||||
return mailValidationParamError("--inline", "--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
|
||||
return output.ErrValidation("--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
|
||||
}
|
||||
}
|
||||
inlineSpecs, err := parseInlineSpecs(inlineFlag)
|
||||
@@ -2560,12 +2536,7 @@ func validateEventFlags(runtime *common.RuntimeContext) error {
|
||||
hasAll := summary != "" && start != "" && end != ""
|
||||
|
||||
if hasAny && !hasAll {
|
||||
return mailValidationError("--event-summary, --event-start, and --event-end must all be provided together").
|
||||
WithParams(
|
||||
mailInvalidParam("--event-summary", "required with --event-start/--event-end"),
|
||||
mailInvalidParam("--event-start", "required with --event-summary/--event-end"),
|
||||
mailInvalidParam("--event-end", "required with --event-summary/--event-start"),
|
||||
)
|
||||
return output.ErrValidation("--event-summary, --event-start, and --event-end must all be provided together")
|
||||
}
|
||||
if summary == "" {
|
||||
return nil
|
||||
@@ -2582,14 +2553,14 @@ func validateEventFlags(runtime *common.RuntimeContext) error {
|
||||
func parseEventTimeRange(start, end string) (time.Time, time.Time, error) {
|
||||
startT, err := parseISO8601(start)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, mailValidationError("start: invalid ISO 8601 time %q", start).WithCause(err)
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("start: invalid ISO 8601 time %q", start)
|
||||
}
|
||||
endT, err := parseISO8601(end)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, mailValidationError("end: invalid ISO 8601 time %q", end).WithCause(err)
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("end: invalid ISO 8601 time %q", end)
|
||||
}
|
||||
if !endT.After(startT) {
|
||||
return time.Time{}, time.Time{}, mailValidationError("end time must be after start time")
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("end time must be after start time")
|
||||
}
|
||||
return startT, endT, nil
|
||||
}
|
||||
@@ -2598,27 +2569,12 @@ func parseEventTimeRange(start, end string) (time.Time, time.Time, error) {
|
||||
// error with the caller's flag-name prefix so users see the exact flag
|
||||
// that caused the failure.
|
||||
func prefixEventRangeError(flagPrefix string, err error) error {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
msg := p.Message
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.HasPrefix(msg, "start: "):
|
||||
p.Message = fmt.Sprintf("%sstart: %s", flagPrefix, strings.TrimPrefix(msg, "start: "))
|
||||
p.Subtype = errs.SubtypeInvalidArgument
|
||||
if strings.HasPrefix(flagPrefix, "--") && errors.As(err, &validationErr) {
|
||||
validationErr.Param = flagPrefix + "start"
|
||||
}
|
||||
return err
|
||||
return fmt.Errorf("%sstart: %s", flagPrefix, strings.TrimPrefix(msg, "start: "))
|
||||
case strings.HasPrefix(msg, "end: "):
|
||||
p.Message = fmt.Sprintf("%send: %s", flagPrefix, strings.TrimPrefix(msg, "end: "))
|
||||
p.Subtype = errs.SubtypeInvalidArgument
|
||||
if strings.HasPrefix(flagPrefix, "--") && errors.As(err, &validationErr) {
|
||||
validationErr.Param = flagPrefix + "end"
|
||||
}
|
||||
return err
|
||||
return fmt.Errorf("%send: %s", flagPrefix, strings.TrimPrefix(msg, "end: "))
|
||||
default:
|
||||
return err
|
||||
}
|
||||
@@ -2639,7 +2595,7 @@ func parseISO8601(s string) (time.Time, error) {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, mailValidationError("cannot parse %q as ISO 8601", s)
|
||||
return time.Time{}, fmt.Errorf("cannot parse %q as ISO 8601", s)
|
||||
}
|
||||
|
||||
// buildCalendarBody generates an ICS VCALENDAR from compose flags and returns the bytes.
|
||||
@@ -2658,8 +2614,8 @@ func buildCalendarBody(runtime *common.RuntimeContext, senderEmail string, toAdd
|
||||
// bot uses tenant access token; "me" cannot be resolved to a user mailbox under TAT.
|
||||
func validateBotMailboxNotMe(runtime *common.RuntimeContext) error {
|
||||
if runtime.IsBot() && runtime.Str("mailbox") == "me" {
|
||||
return mailValidationParamError("--mailbox",
|
||||
"--as bot does not support --mailbox me: bot identity uses a tenant token and cannot resolve \"me\" to a user mailbox; "+
|
||||
return output.ErrValidation(
|
||||
"--as bot does not support --mailbox me: bot identity uses a tenant token and cannot resolve \"me\" to a user mailbox; " +
|
||||
"pass an explicit email address, e.g. --mailbox alice@example.com")
|
||||
}
|
||||
return nil
|
||||
@@ -2671,7 +2627,7 @@ func validateBotMailboxNotMe(runtime *common.RuntimeContext) error {
|
||||
// fetchFullMessages chunks backend requests into batches of 20.
|
||||
func validateMessageIDs(raw string) ([]string, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids is required; provide one or more message IDs separated by commas")
|
||||
return nil, output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas")
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]string, 0, len(parts))
|
||||
@@ -2679,16 +2635,16 @@ func validateMessageIDs(raw string) ([]string, error) {
|
||||
for i, part := range parts {
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
return nil, output.ErrValidation("--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
}
|
||||
if part != id {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
|
||||
return nil, output.ErrValidation("--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
|
||||
}
|
||||
if err := validateBatchGetMessageID(id, i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): duplicate message ID is not allowed", i+1, id)
|
||||
return nil, output.ErrValidation("--message-ids entry %d (%q): duplicate message ID is not allowed", i+1, id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
@@ -2698,14 +2654,11 @@ func validateMessageIDs(raw string) ([]string, error) {
|
||||
|
||||
func validateBatchGetMessageID(id string, index int) error {
|
||||
if strings.Trim(id, "0123456789") == "" {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported by mail +messages; pass the Open API message_id from mail output", index+1, id)
|
||||
return output.ErrValidation("--message-ids entry %d (%q): numeric primary IDs are not supported by mail +messages; pass the Open API message_id from mail output", index+1, id)
|
||||
}
|
||||
decoded, rawErr := base64.RawURLEncoding.DecodeString(id)
|
||||
if rawErr != nil {
|
||||
decoded, rawErr = base64.URLEncoding.DecodeString(id)
|
||||
}
|
||||
if rawErr != nil || len(decoded) == 0 {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): expected a base64url Open API mail message_id from mail output", index+1, id)
|
||||
decoded, err := base64.URLEncoding.DecodeString(id)
|
||||
if err != nil || len(decoded) == 0 {
|
||||
return output.ErrValidation("--message-ids entry %d (%q): expected a base64url Open API mail message_id from mail output", index+1, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1353,34 +1353,6 @@ func TestValidateComposeInlineAndAttachments(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveByNameAcceptsExactID(t *testing.T) {
|
||||
folders := []folderInfo{{ID: "fld_custom", Name: "Team"}}
|
||||
got, err := resolveByName("folder", "fld_custom", "me", folders,
|
||||
func(item folderInfo) string { return item.ID },
|
||||
func(item folderInfo) string { return item.Name },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveByName returned error: %v", err)
|
||||
}
|
||||
if got != "fld_custom" {
|
||||
t.Fatalf("resolveByName exact ID = %q, want fld_custom", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNameValueByNameAllowDuplicatesAcceptsExactID(t *testing.T) {
|
||||
folders := []folderInfo{{ID: "fld_custom", Name: "Parent/Team"}}
|
||||
got, err := resolveNameValueByNameAllowDuplicates("folder", "fld_custom", "me", folders,
|
||||
func(item folderInfo) string { return item.ID },
|
||||
func(item folderInfo) string { return item.Name },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveNameValueByNameAllowDuplicates returned error: %v", err)
|
||||
}
|
||||
if got != "Parent/Team" {
|
||||
t.Fatalf("query name for exact ID = %q, want Parent/Team", got)
|
||||
}
|
||||
}
|
||||
|
||||
// newRequestReceiptRuntime registers the --request-receipt bool flag alone
|
||||
// (no --from), so requireSenderForRequestReceipt tests can drive the flag
|
||||
// directly without pulling in unrelated compose plumbing.
|
||||
@@ -1550,16 +1522,16 @@ func TestParseEventTimeRange_InvalidEnd(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPrefixEventRangeError(t *testing.T) {
|
||||
start := mailValidationError("start: invalid ISO 8601 time %q", "x")
|
||||
start := fmt.Errorf("start: invalid ISO 8601 time %q", "x")
|
||||
if got := prefixEventRangeError("--event-", start).Error(); got != `--event-start: invalid ISO 8601 time "x"` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
end := mailValidationError("end: invalid ISO 8601 time %q", "x")
|
||||
end := fmt.Errorf("end: invalid ISO 8601 time %q", "x")
|
||||
if got := prefixEventRangeError("--set-event-", end).Error(); got != `--set-event-end: invalid ISO 8601 time "x"` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
// Non-prefixed error passes through unchanged.
|
||||
other := mailValidationError("end time must be after start time")
|
||||
other := fmt.Errorf("end time must be after start time")
|
||||
if got := prefixEventRangeError("--event-", other).Error(); got != "end time must be after start time" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -122,11 +121,11 @@ func statAttachmentFiles(fio fileio.FileIO, paths []string) ([]attachmentFile, e
|
||||
}
|
||||
name := filepath.Base(p)
|
||||
if err := filecheck.CheckBlockedExtension(name); err != nil {
|
||||
return nil, mailValidationError("%v", err).WithCause(err)
|
||||
return nil, err
|
||||
}
|
||||
info, err := fio.Stat(p)
|
||||
if err != nil {
|
||||
return nil, mailInputStatError(err)
|
||||
return nil, fmt.Errorf("failed to stat attachment %s: %w", p, err)
|
||||
}
|
||||
files = append(files, attachmentFile{
|
||||
Path: p,
|
||||
@@ -145,7 +144,7 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext,
|
||||
}
|
||||
userOpenId := runtime.UserOpenId()
|
||||
if userOpenId == "" {
|
||||
return nil, mailFailedPreconditionError("large attachment upload requires user identity (user open_id not available)")
|
||||
return nil, fmt.Errorf("large attachment upload requires user identity (user open_id not available)")
|
||||
}
|
||||
|
||||
results := make([]largeAttachmentResult, 0, len(files))
|
||||
@@ -157,7 +156,7 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext,
|
||||
err error
|
||||
)
|
||||
if f.Data != nil {
|
||||
fileToken, err = common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FileName: f.FileName,
|
||||
FileSize: f.Size,
|
||||
ParentType: "email",
|
||||
@@ -165,7 +164,7 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext,
|
||||
Reader: bytes.NewReader(f.Data),
|
||||
})
|
||||
} else if f.Size <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
fileToken, err = common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: f.Path,
|
||||
FileName: f.FileName,
|
||||
FileSize: f.Size,
|
||||
@@ -173,7 +172,7 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext,
|
||||
ParentNode: &userOpenId,
|
||||
})
|
||||
} else {
|
||||
fileToken, err = common.UploadDriveMediaMultipartTyped(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: f.Path,
|
||||
FileName: f.FileName,
|
||||
FileSize: f.Size,
|
||||
@@ -182,7 +181,7 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, mailDecorateProblemMessage(err, "failed to upload large attachment %s", f.FileName)
|
||||
return nil, fmt.Errorf("failed to upload large attachment %s: %w", f.FileName, err)
|
||||
}
|
||||
|
||||
results = append(results, largeAttachmentResult{
|
||||
@@ -398,7 +397,7 @@ func processLargeAttachments(
|
||||
) (emlbuilder.Builder, error) {
|
||||
totalCount := extraAttachCount + len(attachPaths)
|
||||
if totalCount > MaxAttachmentCount {
|
||||
return bld, mailFailedPreconditionError("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount)
|
||||
return bld, fmt.Errorf("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount)
|
||||
}
|
||||
|
||||
files, err := statAttachmentFiles(runtime.FileIO(), attachPaths)
|
||||
@@ -408,7 +407,7 @@ func processLargeAttachments(
|
||||
|
||||
for _, f := range files {
|
||||
if f.Size > MaxLargeAttachmentSize {
|
||||
return bld, mailFailedPreconditionError("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
|
||||
return bld, fmt.Errorf("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
|
||||
f.FileName, float64(f.Size)/1024/1024/1024, float64(MaxLargeAttachmentSize)/1024/1024/1024)
|
||||
}
|
||||
}
|
||||
@@ -423,7 +422,7 @@ func processLargeAttachments(
|
||||
}
|
||||
|
||||
if htmlBody == "" && textBody == "" {
|
||||
return bld, mailFailedPreconditionError("large attachments require a body; " +
|
||||
return bld, fmt.Errorf("large attachments require a body; " +
|
||||
"empty messages cannot include the download link")
|
||||
}
|
||||
|
||||
@@ -432,7 +431,7 @@ func processLargeAttachments(
|
||||
for _, f := range files {
|
||||
totalBytes += f.Size
|
||||
}
|
||||
return bld, mailFailedPreconditionError("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
|
||||
return bld, fmt.Errorf("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
|
||||
"large attachment upload requires user identity (--as user)",
|
||||
float64(totalBytes)/1024/1024)
|
||||
}
|
||||
@@ -456,7 +455,7 @@ func processLargeAttachments(
|
||||
}
|
||||
idsJSON, err := json.Marshal(ids)
|
||||
if err != nil {
|
||||
return bld, errs.NewInternalError(errs.SubtypeSDKError, "failed to encode large attachment IDs: %v", err).WithCause(err)
|
||||
return bld, fmt.Errorf("failed to encode large attachment IDs: %w", err)
|
||||
}
|
||||
bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, base64.StdEncoding.EncodeToString(idsJSON))
|
||||
|
||||
@@ -589,7 +588,7 @@ func preprocessLargeAttachmentsForDraftEdit(
|
||||
// Check 3GB single file limit.
|
||||
for _, f := range files {
|
||||
if f.Size > MaxLargeAttachmentSize {
|
||||
return patch, mailFailedPreconditionError("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
|
||||
return patch, fmt.Errorf("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
|
||||
f.FileName, float64(f.Size)/1024/1024/1024, float64(MaxLargeAttachmentSize)/1024/1024/1024)
|
||||
}
|
||||
}
|
||||
@@ -607,7 +606,7 @@ func preprocessLargeAttachmentsForDraftEdit(
|
||||
hasHTML := draftpkg.FindHTMLBodyPart(snapshot.Body) != nil
|
||||
hasText := draftpkg.FindTextBodyPart(snapshot.Body) != nil
|
||||
if !hasHTML && !hasText {
|
||||
return patch, mailFailedPreconditionError("large attachments require a body; " +
|
||||
return patch, fmt.Errorf("large attachments require a body; " +
|
||||
"empty drafts cannot include the download link")
|
||||
}
|
||||
|
||||
@@ -617,7 +616,7 @@ func preprocessLargeAttachmentsForDraftEdit(
|
||||
for _, f := range files {
|
||||
totalBytes += f.Size
|
||||
}
|
||||
return patch, mailFailedPreconditionError("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
|
||||
return patch, fmt.Errorf("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
|
||||
"large attachment upload requires user identity (--as user)",
|
||||
float64(totalBytes)/1024/1024)
|
||||
}
|
||||
@@ -673,7 +672,7 @@ func preprocessLargeAttachmentsForDraftEdit(
|
||||
}
|
||||
idsJSON, err := json.Marshal(merged)
|
||||
if err != nil {
|
||||
return patch, errs.NewInternalError(errs.SubtypeSDKError, "failed to encode large attachment IDs: %v", err).WithCause(err)
|
||||
return patch, fmt.Errorf("failed to encode large attachment IDs: %w", err)
|
||||
}
|
||||
headerValue := base64.StdEncoding.EncodeToString(idsJSON)
|
||||
if existingIdx >= 0 {
|
||||
|
||||
@@ -974,7 +974,7 @@ func TestStatAttachmentFiles_FileNotFound(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
if !strings.Contains(err.Error(), "failed to stat") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,7 +395,8 @@ func sanitiseStyleAttr(raw string) (cleaned string, dropped []string) {
|
||||
return cleaned, dropped
|
||||
}
|
||||
|
||||
// hintForBlockedTag returns a hint for an error-blocked tag.
|
||||
// hintForBlockedTag returns a hint for an error-blocked tag (matching
|
||||
// the `output.ErrWithHint` convention used elsewhere in the cli).
|
||||
func hintForBlockedTag(tag string) string {
|
||||
switch tag {
|
||||
case "script":
|
||||
|
||||
@@ -59,7 +59,7 @@ var MailDeclineReceipt = common.Shortcut{
|
||||
|
||||
msg, err := fetchFullMessage(runtime, mailboxID, messageID, false)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to fetch original message")
|
||||
return fmt.Errorf("failed to fetch original message: %w", err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
@@ -77,14 +77,14 @@ var MailDeclineReceipt = common.Shortcut{
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := runtime.CallAPITyped("PUT",
|
||||
if _, err := runtime.CallAPI("PUT",
|
||||
mailboxPath(mailboxID, "messages", messageID, "modify"),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"remove_label_ids": []string{readReceiptRequestLabel},
|
||||
},
|
||||
); err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to clear READ_RECEIPT_REQUEST label")
|
||||
return fmt.Errorf("failed to clear READ_RECEIPT_REQUEST label: %w", err)
|
||||
}
|
||||
|
||||
out["declined"] = true
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
@@ -90,7 +91,7 @@ var MailDraftCreate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
|
||||
return mailValidationParamError("--subject", "--subject is required; pass the final email subject (or use --template-id)")
|
||||
return output.ErrValidation("--subject is required; pass the final email subject (or use --template-id)")
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
@@ -175,10 +176,10 @@ var MailDraftCreate = common.Shortcut{
|
||||
})
|
||||
}
|
||||
if strings.TrimSpace(input.Subject) == "" {
|
||||
return mailValidationParamError("--subject", "effective subject is empty after applying template; pass --subject explicitly")
|
||||
return output.ErrValidation("effective subject is empty after applying template; pass --subject explicitly")
|
||||
}
|
||||
if strings.TrimSpace(input.Body) == "" {
|
||||
return mailValidationParamError("--body", "effective body is empty after applying template; pass --body explicitly")
|
||||
return output.ErrValidation("effective body is empty after applying template; pass --body explicitly")
|
||||
}
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
|
||||
if err != nil {
|
||||
@@ -191,7 +192,7 @@ var MailDraftCreate = common.Shortcut{
|
||||
}
|
||||
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "create draft failed")
|
||||
return fmt.Errorf("create draft failed: %w", err)
|
||||
}
|
||||
out := map[string]interface{}{"draft_id": draftResult.DraftID}
|
||||
if draftResult.Reference != "" {
|
||||
@@ -249,7 +250,7 @@ func buildRawEMLForDraftCreate(
|
||||
|
||||
senderEmail := resolveComposeSenderEmail(runtime)
|
||||
if senderEmail == "" {
|
||||
return "", lintApplied, lintBlocked, mailValidationParamError("--from", "unable to determine sender email; please specify --from explicitly")
|
||||
return "", lintApplied, lintBlocked, fmt.Errorf("unable to determine sender email; please specify --from explicitly")
|
||||
}
|
||||
|
||||
if err := validateRecipientCount(input.To, input.CC, input.BCC); err != nil {
|
||||
@@ -284,7 +285,7 @@ func buildRawEMLForDraftCreate(
|
||||
}
|
||||
inlineSpecs, parseErr := parseInlineSpecs(input.Inline)
|
||||
if parseErr != nil {
|
||||
return "", lintApplied, lintBlocked, parseErr
|
||||
return "", lintApplied, lintBlocked, output.ErrValidation("%v", parseErr)
|
||||
}
|
||||
var autoResolvedPaths []string
|
||||
var composedHTMLBody string
|
||||
@@ -299,7 +300,7 @@ func buildRawEMLForDraftCreate(
|
||||
}
|
||||
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
|
||||
if resolveErr != nil {
|
||||
return "", lintApplied, lintBlocked, mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
|
||||
return "", lintApplied, lintBlocked, resolveErr
|
||||
}
|
||||
resolved = injectSignatureIntoBody(resolved, sigResult)
|
||||
// Writing-path lint: AutoFix=true / Strict=false — the writing-path
|
||||
@@ -364,7 +365,7 @@ func buildRawEMLForDraftCreate(
|
||||
}
|
||||
rawEML, buildErr := bld.BuildBase64URL()
|
||||
if buildErr != nil {
|
||||
return "", lintApplied, lintBlocked, mailValidationError("build EML failed: %v", buildErr).WithCause(buildErr)
|
||||
return "", lintApplied, lintBlocked, output.ErrValidation("build EML failed: %v", buildErr)
|
||||
}
|
||||
return rawEML, lintApplied, lintBlocked, nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/ics"
|
||||
@@ -87,7 +88,7 @@ var MailDraftEdit = common.Shortcut{
|
||||
}
|
||||
draftID := runtime.Str("draft-id")
|
||||
if draftID == "" {
|
||||
return mailValidationParamError("--draft-id", "--draft-id is required for real draft edits; if you only need a patch template, run with --print-patch-template")
|
||||
return output.ErrValidation("--draft-id is required for real draft edits; if you only need a patch template, run with --print-patch-template")
|
||||
}
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
if runtime.Bool("inspect") {
|
||||
@@ -99,11 +100,11 @@ var MailDraftEdit = common.Shortcut{
|
||||
}
|
||||
rawDraft, err := draftpkg.GetRaw(runtime, mailboxID, draftID)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "read draft raw EML failed")
|
||||
return fmt.Errorf("read draft raw EML failed: %w", err)
|
||||
}
|
||||
snapshot, err := draftpkg.Parse(rawDraft)
|
||||
if err != nil {
|
||||
return mailFailedPreconditionError("parse draft raw EML failed: %v", err).WithCause(err)
|
||||
return output.ErrValidation("parse draft raw EML failed: %v", err)
|
||||
}
|
||||
// Pre-process ops that need snapshot context: resolve signature using
|
||||
// the draft's From address, and build ICS for set_calendar using the
|
||||
@@ -122,8 +123,8 @@ var MailDraftEdit = common.Shortcut{
|
||||
// Going straight into PatchOp.Value would bypass emlbuilder's
|
||||
// validateHeaderValue gate, so repeat the check here explicitly.
|
||||
if err := validateHeaderAddress(draftFromEmail); err != nil {
|
||||
return mailFailedPreconditionError(
|
||||
"cannot set --request-receipt: draft From address is unsafe for a header (%v)", err).WithCause(err)
|
||||
return output.ErrValidation(
|
||||
"cannot set --request-receipt: draft From address is unsafe for a header (%v)", err)
|
||||
}
|
||||
patch.Ops = append(patch.Ops, draftpkg.PatchOp{
|
||||
Op: "set_header",
|
||||
@@ -146,11 +147,11 @@ var MailDraftEdit = common.Shortcut{
|
||||
if calPart := draftpkg.FindPartByMediaType(snapshot.Body, "text/calendar"); calPart != nil {
|
||||
parsed := ics.ParseEvent(string(calPart.Body))
|
||||
if parsed == nil || !parsed.IsLarkDraft {
|
||||
return mailFailedPreconditionError("set_calendar: calendar event has already been created and is read-only; use --remove-event to remove it, then --set-event-* to create a new one")
|
||||
return output.ErrValidation("set_calendar: calendar event has already been created and is read-only; use --remove-event to remove it, then --set-event-* to create a new one")
|
||||
}
|
||||
}
|
||||
if _, _, err := parseEventTimeRange(patch.Ops[i].EventStart, patch.Ops[i].EventEnd); err != nil {
|
||||
return prefixEventRangeError("set_calendar: ", err)
|
||||
return output.ErrValidation("set_calendar: %v", err)
|
||||
}
|
||||
// Derive effective To/Cc by replaying all pending recipient ops so
|
||||
// the ICS ATTENDEE list matches the final post-edit recipients.
|
||||
@@ -165,7 +166,7 @@ var MailDraftEdit = common.Shortcut{
|
||||
joinAddresses(ccAddrs),
|
||||
)
|
||||
if calData == nil {
|
||||
return mailValidationError("set_calendar: failed to build ICS from event fields")
|
||||
return output.ErrValidation("set_calendar: failed to build ICS from event fields")
|
||||
}
|
||||
patch.Ops[i].CalendarICS = calData
|
||||
}
|
||||
@@ -205,16 +206,16 @@ var MailDraftEdit = common.Shortcut{
|
||||
dctx := &draftpkg.DraftCtx{FIO: runtime.FileIO()}
|
||||
if len(patch.Ops) > 0 {
|
||||
if err := draftpkg.Apply(dctx, snapshot, patch); err != nil {
|
||||
return mailValidationError("apply draft patch failed: %v", err).WithCause(err)
|
||||
return output.ErrValidation("apply draft patch failed: %v", err)
|
||||
}
|
||||
}
|
||||
serialized, err := draftpkg.Serialize(snapshot)
|
||||
if err != nil {
|
||||
return mailValidationError("serialize draft failed: %v", err).WithCause(err)
|
||||
return output.ErrValidation("serialize draft failed: %v", err)
|
||||
}
|
||||
updateResult, err := draftpkg.UpdateWithRaw(runtime, mailboxID, draftID, serialized)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "update draft failed")
|
||||
return fmt.Errorf("update draft failed: %w", err)
|
||||
}
|
||||
projection := draftpkg.Project(snapshot)
|
||||
out := map[string]interface{}{
|
||||
@@ -269,11 +270,11 @@ var MailDraftEdit = common.Shortcut{
|
||||
func executeDraftInspect(runtime *common.RuntimeContext, mailboxID, draftID string) error {
|
||||
rawDraft, err := draftpkg.GetRaw(runtime, mailboxID, draftID)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "read draft raw EML failed")
|
||||
return fmt.Errorf("read draft raw EML failed: %w", err)
|
||||
}
|
||||
snapshot, err := draftpkg.Parse(rawDraft)
|
||||
if err != nil {
|
||||
return mailFailedPreconditionError("parse draft raw EML failed: %v", err).WithCause(err)
|
||||
return output.ErrValidation("parse draft raw EML failed: %v", err)
|
||||
}
|
||||
projection := draftpkg.Project(snapshot)
|
||||
out := map[string]interface{}{
|
||||
@@ -421,12 +422,7 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
|
||||
if bodyVal != "" {
|
||||
for _, op := range patch.Ops {
|
||||
if op.Op == "set_body" || op.Op == "set_reply_body" {
|
||||
return patch, mailValidationError("--body / --body-file and --patch-file body ops (set_body/set_reply_body) are mutually exclusive; use one or the other").
|
||||
WithParams(
|
||||
mailInvalidParam("--body", "mutually exclusive with --patch-file body ops"),
|
||||
mailInvalidParam("--body-file", "mutually exclusive with --patch-file body ops"),
|
||||
mailInvalidParam("--patch-file", "mutually exclusive with direct body flags"),
|
||||
)
|
||||
return patch, output.ErrValidation("--body / --body-file and --patch-file body ops (set_body/set_reply_body) are mutually exclusive; use one or the other")
|
||||
}
|
||||
}
|
||||
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "set_body", Value: bodyVal})
|
||||
@@ -452,29 +448,20 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
|
||||
hasEventSet := runtime.Str("set-event-summary") != ""
|
||||
hasEventRemove := runtime.Bool("remove-event")
|
||||
if !hasEventSet && (runtime.Str("set-event-start") != "" || runtime.Str("set-event-end") != "" || runtime.Str("set-event-location") != "") {
|
||||
return patch, mailValidationParamError("--set-event-summary", "--set-event-start, --set-event-end, and --set-event-location require --set-event-summary")
|
||||
return patch, output.ErrValidation("--set-event-start, --set-event-end, and --set-event-location require --set-event-summary")
|
||||
}
|
||||
if hasEventSet && hasEventRemove {
|
||||
return patch, mailValidationError("--set-event-summary and --remove-event are mutually exclusive").
|
||||
WithParams(
|
||||
mailInvalidParam("--set-event-summary", "mutually exclusive with --remove-event"),
|
||||
mailInvalidParam("--remove-event", "mutually exclusive with --set-event-summary"),
|
||||
)
|
||||
return patch, output.ErrValidation("--set-event-summary and --remove-event are mutually exclusive")
|
||||
}
|
||||
if hasEventSet {
|
||||
summary := runtime.Str("set-event-summary")
|
||||
start := runtime.Str("set-event-start")
|
||||
end := runtime.Str("set-event-end")
|
||||
if summary == "" || start == "" || end == "" {
|
||||
return patch, mailValidationError("--set-event-summary, --set-event-start, and --set-event-end must all be provided together").
|
||||
WithParams(
|
||||
mailInvalidParam("--set-event-summary", "required with --set-event-start/--set-event-end"),
|
||||
mailInvalidParam("--set-event-start", "required with --set-event-summary/--set-event-end"),
|
||||
mailInvalidParam("--set-event-end", "required with --set-event-summary/--set-event-start"),
|
||||
)
|
||||
return patch, output.ErrValidation("--set-event-summary, --set-event-start, and --set-event-end must all be provided together")
|
||||
}
|
||||
if _, _, err := parseEventTimeRange(start, end); err != nil {
|
||||
return patch, prefixEventRangeError("--set-event-", err)
|
||||
return patch, output.ErrValidation("%s", prefixEventRangeError("--set-event-", err).Error())
|
||||
}
|
||||
patch.Ops = append(patch.Ops, draftpkg.PatchOp{
|
||||
Op: "set_calendar",
|
||||
@@ -488,7 +475,7 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
|
||||
}
|
||||
|
||||
if len(patch.Ops) == 0 && !runtime.Bool("request-receipt") {
|
||||
return patch, mailValidationError("at least one edit operation is required; use direct flags such as --set-subject/--set-to, or use --patch-file for body edits and other advanced operations (run --print-patch-template first)")
|
||||
return patch, output.ErrValidation("at least one edit operation is required; use direct flags such as --set-subject/--set-to, or use --patch-file for body edits and other advanced operations (run --print-patch-template first)")
|
||||
}
|
||||
if len(patch.Ops) == 0 {
|
||||
// --request-receipt only: Validate() would reject empty Ops, so skip it
|
||||
@@ -496,10 +483,7 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
|
||||
// the draft's From address is known.
|
||||
return patch, nil
|
||||
}
|
||||
if err := patch.Validate(); err != nil {
|
||||
return patch, mailValidationError("%v", err).WithCause(err)
|
||||
}
|
||||
return patch, nil
|
||||
return patch, patch.Validate()
|
||||
}
|
||||
|
||||
// loadPatchFile reads and JSON-decodes a patch file from a relative path
|
||||
@@ -508,25 +492,19 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
|
||||
// internal stack traces.
|
||||
func loadPatchFile(runtime *common.RuntimeContext, path string) (draftpkg.Patch, error) {
|
||||
var patch draftpkg.Patch
|
||||
if err := runtime.ValidatePath(path); err != nil {
|
||||
return patch, mailValidationParamError("--patch-file", "--patch-file %q: %v", path, err).WithCause(mailInputStatError(err))
|
||||
}
|
||||
f, err := runtime.FileIO().Open(path)
|
||||
if err != nil {
|
||||
return patch, mailValidationParamError("--patch-file", "--patch-file %q: %v", path, err).WithCause(mailInputStatError(err))
|
||||
return patch, fmt.Errorf("--patch-file %q: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return patch, mailValidationParamError("--patch-file", "read --patch-file %q: %v", path, err).WithCause(err)
|
||||
return patch, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &patch); err != nil {
|
||||
return patch, mailValidationParamError("--patch-file", "parse patch file: %v", err).WithCause(err)
|
||||
return patch, fmt.Errorf("parse patch file: %w", err)
|
||||
}
|
||||
if err := patch.Validate(); err != nil {
|
||||
return patch, mailValidationParamError("--patch-file", "validate patch file: %v", err).WithCause(err)
|
||||
}
|
||||
return patch, nil
|
||||
return patch, patch.Validate()
|
||||
}
|
||||
|
||||
// buildDraftEditPatchTemplate returns the JSON template emitted by
|
||||
|
||||
@@ -4,11 +4,8 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -85,43 +82,6 @@ func TestBuildDraftEditPatch_InvalidPriority(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPatchFileRejectsUnsafePathWithTypedParam(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
f, _, _, _ := mailShortcutTestFactory(t)
|
||||
rt := &common.RuntimeContext{Cmd: &cobra.Command{Use: "test"}, Factory: f, Config: mailTestConfig()}
|
||||
_, err := loadPatchFile(rt, "../patch.json")
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe patch path to fail")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--patch-file" {
|
||||
t.Fatalf("param = %q, want --patch-file", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPatchFileValidateFailureKeepsPatchFileParam(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("patch.json", []byte(`{"ops":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, _, _, _ := mailShortcutTestFactory(t)
|
||||
rt := &common.RuntimeContext{Cmd: &cobra.Command{Use: "test"}, Factory: f, Config: mailTestConfig()}
|
||||
_, err := loadPatchFile(rt, "patch.json")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid patch file to fail")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--patch-file" {
|
||||
t.Fatalf("param = %q, want --patch-file", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_NoPriority(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-subject": "hello"})
|
||||
patch, err := buildDraftEditPatch(rt)
|
||||
|
||||
@@ -5,10 +5,11 @@ package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -45,21 +46,11 @@ type failedDraft struct {
|
||||
// "success_count": 2,
|
||||
// "failure_count": 1,
|
||||
// "sent": [{"draft_id":..., "message_id":..., "thread_id":...}, ...],
|
||||
// "failed":[{"draft_id":..., "error":...}],
|
||||
// "aborted": true,
|
||||
// "abort_error": {"type":..., "subtype":..., "code":..., "message":..., "hint":...}
|
||||
// "failed":[{"draft_id":..., "error":...}]
|
||||
// }
|
||||
//
|
||||
// failed is marked omitempty so a fully successful batch returns a clean shape
|
||||
// without an empty array.
|
||||
//
|
||||
// aborted reports an account-level abort: the failure repeats identically for
|
||||
// every draft, so the remaining drafts were not attempted and retrying the
|
||||
// batch as-is fails the same way. abort_error carries the typed error that
|
||||
// triggered the abort (same wire shape as a stderr error envelope's error
|
||||
// object) so callers can route recovery from stdout alone. A --stop-on-error
|
||||
// stop does NOT set aborted: there the failure is draft-level and the caller
|
||||
// chose to stop early.
|
||||
type batchSendOutput struct {
|
||||
MailboxID string `json:"mailbox_id"`
|
||||
Total int `json:"total"`
|
||||
@@ -67,8 +58,6 @@ type batchSendOutput struct {
|
||||
FailureCount int `json:"failure_count"`
|
||||
Sent []sentDraft `json:"sent"`
|
||||
Failed []failedDraft `json:"failed,omitempty"`
|
||||
Aborted bool `json:"aborted,omitempty"`
|
||||
AbortError interface{} `json:"abort_error,omitempty"`
|
||||
}
|
||||
|
||||
// MailDraftSend is the `+draft-send` shortcut: send N existing drafts
|
||||
@@ -77,9 +66,9 @@ type batchSendOutput struct {
|
||||
// drafts are user-owned resources and bot has no coherent semantics here.
|
||||
//
|
||||
// Output schema is the batchSendOutput type above. Partial failures (any
|
||||
// failed[]) emit an ok:false multi-status envelope so that agents can
|
||||
// distinguish "all sent" from "some sent" without parsing the success_count
|
||||
// field.
|
||||
// failed[]) return exit 1 with envelope.error.type="partial_failure" so that
|
||||
// agents can distinguish "all sent" from "some sent" without parsing the
|
||||
// success_count field.
|
||||
var MailDraftSend = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+draft-send",
|
||||
@@ -112,16 +101,14 @@ var MailDraftSend = common.Shortcut{
|
||||
// 2. Validate the draft-id slice (non-empty, under MaxBatchSendDrafts cap,
|
||||
// no empty elements).
|
||||
// 3. Loop over each draft ID, calling POST .../drafts/:id/send directly via
|
||||
// runtime.CallAPITyped. Per-draft outcomes:
|
||||
// - fatal err (isFatalSendErr) → abort immediately (bypasses
|
||||
// --stop-on-error): with earlier progress, emit the aborted ledger as the
|
||||
// single failure result; with none, return the typed error directly.
|
||||
// runtime.CallAPI. Per-draft outcomes:
|
||||
// - fatal err (isFatalSendErr) → return immediately (bypasses --stop-on-error).
|
||||
// - recoverable err → append to failed[]; honor --stop-on-error.
|
||||
// - success + automation_send_disable signal → abort the same way with a
|
||||
// failed-precondition error.
|
||||
// - success + automation_send_disable signal → return immediately with
|
||||
// ExitAPI/"automation_send_disabled".
|
||||
// - success → append to sent[].
|
||||
// 4. Emit batchSendOutput via runtime.Out.
|
||||
// 5. If any draft failed, emit ok:false via runtime.OutPartialFailure.
|
||||
// 5. If any draft failed, return ExitAPI/"partial_failure" so exit code = 1.
|
||||
func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
mailboxID := resolveComposeMailboxID(rt)
|
||||
draftIDs, err := normalizedDraftSendIDs(rt)
|
||||
@@ -135,9 +122,9 @@ func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
idx := i + 1
|
||||
writeDraftSendProgressf(rt, "[%d/%d] sending draft %s",
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id))
|
||||
// Direct CallAPITyped rather than draftpkg.Send: this shortcut never sends
|
||||
// Direct CallAPI rather than draftpkg.Send: this shortcut never sends
|
||||
// a body, so the helper's send_time-aware envelope would add no value.
|
||||
data, err := rt.CallAPITyped("POST",
|
||||
data, err := rt.CallAPI("POST",
|
||||
mailboxPath(mailboxID, "drafts", id, "send"), nil, nil)
|
||||
if err != nil {
|
||||
if isFatalSendErr(err) {
|
||||
@@ -145,15 +132,13 @@ func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
|
||||
hadProgress := out.hasProgress()
|
||||
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
|
||||
if hadProgress {
|
||||
emitDraftSendOutput(rt, &out)
|
||||
}
|
||||
// Account- / mailbox-level failures (auth, permission, network,
|
||||
// quota) will repeat identically for every remaining draft —
|
||||
// abort immediately so the caller sees a single clear error
|
||||
// instead of 100 redundant failed[] entries. With earlier
|
||||
// progress the aborted ledger is the single failure result;
|
||||
// with none, stdout stays empty and the typed error envelope is.
|
||||
if hadProgress {
|
||||
return emitDraftSendAborted(rt, &out, err)
|
||||
}
|
||||
// instead of 100 redundant failed[] entries.
|
||||
return err
|
||||
}
|
||||
writeDraftSendProgressf(rt, "[%d/%d] failed draft %s: %s",
|
||||
@@ -165,19 +150,17 @@ func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
continue
|
||||
}
|
||||
if reason := extractAutomationDisabledReason(data); reason != "" {
|
||||
err := mailFailedPreconditionError(
|
||||
"automation send is disabled for this mailbox: %s", reason).
|
||||
WithHint("enable automation send for this mailbox, or send the draft from the Lark client")
|
||||
err := output.Errorf(output.ExitAPI, "automation_send_disabled",
|
||||
"automation send is disabled for this mailbox: %s", reason)
|
||||
writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s",
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
|
||||
// HTTP success (code: 0) but the backend signaled automation send
|
||||
// is disabled — every subsequent send will fail the same way, so
|
||||
// abort the batch with a single failure result: the aborted ledger
|
||||
// when earlier drafts made progress, the typed error otherwise.
|
||||
if out.hasProgress() {
|
||||
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
|
||||
return emitDraftSendAborted(rt, &out, err)
|
||||
emitDraftSendOutput(rt, &out)
|
||||
}
|
||||
// HTTP success (code: 0) but the backend signaled automation send
|
||||
// is disabled — every subsequent send will fail the same way, so
|
||||
// abort the batch with a single descriptive error.
|
||||
return err
|
||||
}
|
||||
s := sentDraft{DraftID: id}
|
||||
@@ -196,11 +179,13 @@ func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id))
|
||||
}
|
||||
}
|
||||
if len(out.Failed) == 0 {
|
||||
emitDraftSendOutput(rt, &out)
|
||||
emitDraftSendOutput(rt, &out)
|
||||
|
||||
if out.FailureCount == 0 {
|
||||
return nil
|
||||
}
|
||||
return emitDraftSendPartialFailure(rt, &out)
|
||||
return output.Errorf(output.ExitAPI, "partial_failure",
|
||||
"%d of %d drafts failed to send", out.FailureCount, out.Total)
|
||||
}
|
||||
|
||||
// dryRunDraftSend builds the --dry-run preview: one POST call per draft ID,
|
||||
@@ -227,7 +212,7 @@ func normalizedDraftSendIDs(rt *common.RuntimeContext) ([]string, error) {
|
||||
|
||||
func normalizeDraftSendIDs(draftIDs []string) ([]string, error) {
|
||||
if len(draftIDs) == 0 {
|
||||
return nil, mailValidationParamError("--draft-id", "--draft-id is required")
|
||||
return nil, output.ErrValidation("--draft-id is required")
|
||||
}
|
||||
|
||||
normalized := make([]string, 0, len(draftIDs))
|
||||
@@ -235,16 +220,16 @@ func normalizeDraftSendIDs(draftIDs []string) ([]string, error) {
|
||||
for _, id := range draftIDs {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed == "" {
|
||||
return nil, mailValidationParamError("--draft-id", "--draft-id contains empty value")
|
||||
return nil, output.ErrValidation("--draft-id contains empty value")
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
return nil, mailValidationParamError("--draft-id", "--draft-id contains duplicate value: %s", trimmed)
|
||||
return nil, output.ErrValidation("--draft-id contains duplicate value: %s", trimmed)
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
normalized = append(normalized, trimmed)
|
||||
}
|
||||
if len(normalized) > MaxBatchSendDrafts {
|
||||
return nil, mailValidationParamError("--draft-id",
|
||||
return nil, output.ErrValidation(
|
||||
"too many drafts: %d > %d (split into multiple batches)",
|
||||
len(normalized), MaxBatchSendDrafts)
|
||||
}
|
||||
@@ -261,24 +246,6 @@ func emitDraftSendOutput(rt *common.RuntimeContext, out *batchSendOutput) {
|
||||
rt.Out(*out, nil)
|
||||
}
|
||||
|
||||
func emitDraftSendPartialFailure(rt *common.RuntimeContext, out *batchSendOutput) error {
|
||||
out.SuccessCount = len(out.Sent)
|
||||
out.FailureCount = len(out.Failed)
|
||||
return rt.OutPartialFailure(*out, nil)
|
||||
}
|
||||
|
||||
// emitDraftSendAborted emits the batch ledger as the single failure result for
|
||||
// an account-level abort: the ledger carries aborted/abort_error and the
|
||||
// returned partial-failure signal sets the exit code without a second error
|
||||
// envelope on stderr.
|
||||
func emitDraftSendAborted(rt *common.RuntimeContext, out *batchSendOutput, cause error) error {
|
||||
out.Aborted = true
|
||||
if typed, ok := errs.UnwrapTypedError(errs.WrapInternal(cause)); ok {
|
||||
out.AbortError = typed
|
||||
}
|
||||
return emitDraftSendPartialFailure(rt, out)
|
||||
}
|
||||
|
||||
func writeDraftSendProgressf(rt *common.RuntimeContext, format string, args ...interface{}) {
|
||||
if rt == nil || rt.Factory == nil || rt.Factory.IOStreams == nil || rt.Factory.IOStreams.ErrOut == nil {
|
||||
return
|
||||
@@ -292,38 +259,52 @@ func writeDraftSendProgressf(rt *common.RuntimeContext, format string, args ...i
|
||||
//
|
||||
// Trigger conditions:
|
||||
//
|
||||
// - err does not expose a typed Problem:
|
||||
// - err does not unwrap to an *output.ExitError, or its Detail is missing:
|
||||
// unknown shapes are treated as fatal so they cannot accidentally
|
||||
// accumulate into failed[] for every remaining draft.
|
||||
// - Problem.Category ∈ {authentication, authorization, config, network,
|
||||
// internal}: token, scope, app-installation problems, throttling,
|
||||
// connectivity, SDK, and invalid-response failures are account-level.
|
||||
// - Problem.Subtype ∈ {rate_limit, quota_exceeded}: throttling and quota
|
||||
// exhaustion are account-level.
|
||||
// - Problem.Code ∈ {1234013, 1236007, 1236008, 1236009, 1236010, 1236013}:
|
||||
// mailbox missing / quota exhaustion is account-level. Mailbox-not-found
|
||||
// stays code-scoped (1234013) rather than matching subtype not_found, so
|
||||
// an unrelated not_found — e.g. a single bad draft ID — remains a
|
||||
// per-draft recoverable failure.
|
||||
// - Detail.Type ∈ {"auth", "app_status", "config", "permission",
|
||||
// "rate_limit", "network"}: token, scope, app-installation problems,
|
||||
// throttling, and connectivity are account-level.
|
||||
// - Code == output.ExitNetwork: connectivity loss is account-level.
|
||||
// - Detail.Code ∈ {LarkErrMailboxNotFound, LarkErrMailSendQuotaUser,
|
||||
// LarkErrMailSendQuotaUserExt, LarkErrMailSendQuotaTenantExt,
|
||||
// LarkErrMailQuota, LarkErrTenantStorageLimit}: mailbox / quota
|
||||
// exhaustion is account-level.
|
||||
func isFatalSendErr(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
return true
|
||||
}
|
||||
switch p.Category {
|
||||
case errs.CategoryAuthentication, errs.CategoryAuthorization, errs.CategoryConfig, errs.CategoryNetwork, errs.CategoryInternal:
|
||||
switch exitErr.Detail.Type {
|
||||
case "auth", "app_status", "config":
|
||||
return true
|
||||
case "permission", "rate_limit", "network":
|
||||
return true
|
||||
}
|
||||
if p.Subtype == errs.SubtypeRateLimit || p.Subtype == errs.SubtypeQuotaExceeded {
|
||||
if exitErr.Code == output.ExitNetwork || wrapsExitCode(err, output.ExitNetwork) {
|
||||
return true
|
||||
}
|
||||
switch p.Code {
|
||||
case 1234013, 1236007, 1236008, 1236009, 1236010, 1236013:
|
||||
switch exitErr.Detail.Code {
|
||||
case output.LarkErrMailboxNotFound,
|
||||
output.LarkErrMailSendQuotaUser,
|
||||
output.LarkErrMailSendQuotaUserExt,
|
||||
output.LarkErrMailSendQuotaTenantExt,
|
||||
output.LarkErrMailQuota,
|
||||
output.LarkErrTenantStorageLimit:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func wrapsExitCode(err error, code int) bool {
|
||||
for unwrapped := errors.Unwrap(err); unwrapped != nil; unwrapped = errors.Unwrap(unwrapped) {
|
||||
if exitErr, ok := unwrapped.(*output.ExitError); ok && exitErr.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractAutomationDisabledReason returns the human-readable reason when the
|
||||
// send succeeded at HTTP level (code: 0) but the backend reports that
|
||||
// automation send is disabled for this mailbox. An empty return value means
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -93,32 +91,6 @@ func stubDraftSend(reg *httpmock.Registry, draftID string, body map[string]inter
|
||||
return stub
|
||||
}
|
||||
|
||||
func decodeDraftSendPartialEnvelopeData(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
|
||||
}
|
||||
if envelope.OK {
|
||||
t.Fatalf("expected ok:false partial-failure output, stdout=%s", stdout.String())
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
func assertPartialFailureSignal(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("PartialFailureError.Code = %d, want ExitAPI=%d", pfErr.Code, output.ExitAPI)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_AllSuccess verifies the happy path: every draft sends
|
||||
// successfully, sent[] is fully populated, failed[] is omitted from the JSON,
|
||||
// and exit code = 0 (err == nil).
|
||||
@@ -218,8 +190,7 @@ func TestMailDraftSend_ProgressWritesToStderr(t *testing.T) {
|
||||
if strings.Contains(stdout.String(), "mail +draft-send:") {
|
||||
t.Errorf("stdout must not contain progress lines; got %s", stdout.String())
|
||||
}
|
||||
assertPartialFailureSignal(t, err)
|
||||
data := decodeDraftSendPartialEnvelopeData(t, stdout)
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["success_count"].(float64) != 2 || data["failure_count"].(float64) != 1 {
|
||||
t.Errorf("unexpected aggregate counts: %#v", data)
|
||||
}
|
||||
@@ -227,7 +198,7 @@ func TestMailDraftSend_ProgressWritesToStderr(t *testing.T) {
|
||||
|
||||
// TestMailDraftSend_PartialFailure verifies that one recoverable per-draft
|
||||
// failure does not abort the batch; the remaining drafts are attempted; both
|
||||
// arrays are populated; and the call returns the multi-status partial-failure signal.
|
||||
// arrays are populated; and the call returns ExitAPI/"partial_failure".
|
||||
func TestMailDraftSend_PartialFailure(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
@@ -254,9 +225,18 @@ func TestMailDraftSend_PartialFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected partial_failure error, got nil")
|
||||
}
|
||||
assertPartialFailureSignal(t, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "partial_failure" {
|
||||
t.Errorf("Detail.Type = %v, want partial_failure", exitErr.Detail)
|
||||
}
|
||||
|
||||
data := decodeDraftSendPartialEnvelopeData(t, stdout)
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["total"].(float64) != 3 {
|
||||
t.Errorf("total = %v, want 3", data["total"])
|
||||
}
|
||||
@@ -304,8 +284,7 @@ func TestMailDraftSend_StopOnError(t *testing.T) {
|
||||
t.Fatal("expected partial_failure error, got nil")
|
||||
}
|
||||
|
||||
assertPartialFailureSignal(t, err)
|
||||
data := decodeDraftSendPartialEnvelopeData(t, stdout)
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["success_count"].(float64) != 1 {
|
||||
t.Errorf("success_count = %v, want 1", data["success_count"])
|
||||
}
|
||||
@@ -315,14 +294,6 @@ func TestMailDraftSend_StopOnError(t *testing.T) {
|
||||
if data["total"].(float64) != 3 {
|
||||
t.Errorf("total = %v, want 3", data["total"])
|
||||
}
|
||||
// A --stop-on-error stop is a caller choice over a draft-level failure,
|
||||
// not an account-level abort: the aborted/abort_error fields stay unset.
|
||||
if _, present := data["aborted"]; present {
|
||||
t.Errorf("aborted should be unset for --stop-on-error, got %v", data["aborted"])
|
||||
}
|
||||
if _, present := data["abort_error"]; present {
|
||||
t.Errorf("abort_error should be unset for --stop-on-error, got %v", data["abort_error"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_FatalAborts verifies that a fatal errno (mailbox not
|
||||
@@ -344,12 +315,12 @@ func TestMailDraftSend_FatalAborts(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected fatal abort error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if p.Code != output.LarkErrMailboxNotFound {
|
||||
t.Errorf("expected code = %d, got %#v", output.LarkErrMailboxNotFound, p)
|
||||
if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailboxNotFound {
|
||||
t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailboxNotFound, exitErr.Detail)
|
||||
}
|
||||
// No JSON envelope on stdout because Execute returned early before rt.Out.
|
||||
if stdout.Len() != 0 {
|
||||
@@ -358,10 +329,9 @@ func TestMailDraftSend_FatalAborts(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestMailDraftSend_FatalAfterSuccessEmitsLedger verifies that a fatal error
|
||||
// after earlier side effects emits the aborted stdout ledger as the single
|
||||
// failure result: the returned partial-failure signal sets the exit code
|
||||
// without a second error envelope, and abort_error carries the typed cause so
|
||||
// callers can avoid blindly retrying a draft that was already sent.
|
||||
// after earlier side effects still emits the aggregate stdout ledger before
|
||||
// returning the fatal stderr error. This lets callers avoid blindly retrying a
|
||||
// draft that was already sent.
|
||||
func TestMailDraftSend_FatalAfterSuccessEmitsLedger(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
@@ -379,17 +349,17 @@ func TestMailDraftSend_FatalAfterSuccessEmitsLedger(t *testing.T) {
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial-failure abort signal, got nil")
|
||||
t.Fatal("expected fatal abort error, got nil")
|
||||
}
|
||||
// The ledger is the single failure result: the returned error must be the
|
||||
// envelope-less partial-failure signal, not a typed error that the root
|
||||
// dispatcher would render as a second failure envelope on stderr.
|
||||
assertPartialFailureSignal(t, err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
t.Fatalf("abort signal must not carry a typed problem, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailSendQuotaUser {
|
||||
t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailSendQuotaUser, exitErr.Detail)
|
||||
}
|
||||
|
||||
data := decodeDraftSendPartialEnvelopeData(t, stdout)
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["total"].(float64) != 3 {
|
||||
t.Errorf("total = %v, want 3", data["total"])
|
||||
}
|
||||
@@ -405,19 +375,6 @@ func TestMailDraftSend_FatalAfterSuccessEmitsLedger(t *testing.T) {
|
||||
if got := gjsonLikeString(t, data, "failed", 0, "draft_id"); got != "d2" {
|
||||
t.Errorf("failed[0].draft_id = %q, want d2", got)
|
||||
}
|
||||
if data["aborted"] != true {
|
||||
t.Errorf("aborted = %v, want true", data["aborted"])
|
||||
}
|
||||
abortErr, ok := data["abort_error"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("abort_error = %v, want object", data["abort_error"])
|
||||
}
|
||||
if abortErr["type"] != "api" {
|
||||
t.Errorf("abort_error.type = %v, want api", abortErr["type"])
|
||||
}
|
||||
if abortErr["code"].(float64) != float64(output.LarkErrMailSendQuotaUser) {
|
||||
t.Errorf("abort_error.code = %v, want %d", abortErr["code"], output.LarkErrMailSendQuotaUser)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_AutomationDisabled verifies that an HTTP-success response
|
||||
@@ -445,22 +402,24 @@ func TestMailDraftSend_AutomationDisabled(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected automation_send_disabled error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %#v, want validation/failed_precondition", p)
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if !strings.Contains(p.Message, "outbound automation disabled") {
|
||||
t.Errorf("error message should propagate reason, got %q", p.Message)
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" {
|
||||
t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Error(), "outbound automation disabled") {
|
||||
t.Errorf("error message should propagate reason, got %q", exitErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger verifies that an
|
||||
// automation-send policy stop after earlier successful sends emits the aborted
|
||||
// batch ledger as the single failure result, with the failed-precondition
|
||||
// cause carried in abort_error.
|
||||
// automation-send policy stop after earlier successful sends still writes the
|
||||
// batch ledger to stdout before returning the structured fatal error.
|
||||
func TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
@@ -483,27 +442,17 @@ func TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger(t *testing.T) {
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial-failure abort signal, got nil")
|
||||
t.Fatal("expected automation_send_disabled error, got nil")
|
||||
}
|
||||
assertPartialFailureSignal(t, err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
t.Fatalf("abort signal must not carry a typed problem, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" {
|
||||
t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail)
|
||||
}
|
||||
|
||||
data := decodeDraftSendPartialEnvelopeData(t, stdout)
|
||||
if data["aborted"] != true {
|
||||
t.Errorf("aborted = %v, want true", data["aborted"])
|
||||
}
|
||||
abortErr, ok := data["abort_error"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("abort_error = %v, want object", data["abort_error"])
|
||||
}
|
||||
if abortErr["type"] != "validation" || abortErr["subtype"] != "failed_precondition" {
|
||||
t.Errorf("abort_error type/subtype = %v/%v, want validation/failed_precondition", abortErr["type"], abortErr["subtype"])
|
||||
}
|
||||
if msg, _ := abortErr["message"].(string); !strings.Contains(msg, "outbound automation disabled") {
|
||||
t.Errorf("abort_error.message should carry the reason, got %q", msg)
|
||||
}
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["total"].(float64) != 3 {
|
||||
t.Errorf("total = %v, want 3", data["total"])
|
||||
}
|
||||
@@ -651,8 +600,12 @@ func TestMailDraftSend_MissingYes(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected ExitConfirmationRequired, got nil")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
|
||||
t.Errorf("Code = %d, want ExitConfirmationRequired=%d", code, output.ExitConfirmationRequired)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitConfirmationRequired {
|
||||
t.Errorf("Code = %d, want ExitConfirmationRequired=%d", exitErr.Code, output.ExitConfirmationRequired)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,58 +788,93 @@ func TestIsFatalSendErr(t *testing.T) {
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "internal typed fallback → fatal",
|
||||
err: errs.NewInternalError(errs.SubtypeSDKError, "unexpected shape"),
|
||||
name: "ExitError without Detail → fatal",
|
||||
err: &output.ExitError{Code: output.ExitInternal},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "authentication → fatal",
|
||||
err: errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired"),
|
||||
name: "auth → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{Type: "auth", Message: "token expired"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "authorization → fatal",
|
||||
err: errs.NewPermissionError(errs.SubtypePermissionDenied, "denied"),
|
||||
name: "app_status → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{Type: "app_status", Message: "app disabled"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "config → fatal",
|
||||
err: errs.NewConfigError(errs.SubtypeInvalidConfig, "bad app_id"),
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{Type: "config", Message: "bad app_id"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "network → fatal",
|
||||
err: errs.NewNetworkError(errs.SubtypeNetworkTransport, "DNS timeout"),
|
||||
name: "permission → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "permission", Message: "denied"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "rate_limit → fatal",
|
||||
err: errs.NewAPIError(errs.SubtypeRateLimit, "rate limited").WithCode(output.LarkErrRateLimit),
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "rate_limit", Code: output.LarkErrRateLimit},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ExitNetwork → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitNetwork,
|
||||
Detail: &output.ErrDetail{Type: "network", Message: "DNS timeout"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wrapped ExitNetwork → fatal",
|
||||
err: output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", output.ErrNetwork("DNS timeout")),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "LarkErrMailboxNotFound → fatal",
|
||||
err: errs.NewAPIError(errs.SubtypeNotFound, "mailbox not found").WithCode(output.LarkErrMailboxNotFound),
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailboxNotFound},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "LarkErrMailSendQuotaUser → fatal",
|
||||
err: errs.NewAPIError(errs.SubtypeQuotaExceeded, "user daily send count exceeded").WithCode(output.LarkErrMailSendQuotaUser),
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailSendQuotaUser},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "LarkErrTenantStorageLimit → fatal",
|
||||
err: errs.NewAPIError(errs.SubtypeQuotaExceeded, "tenant storage limit").WithCode(output.LarkErrTenantStorageLimit),
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrTenantStorageLimit},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "generic api unknown → recoverable",
|
||||
err: errs.NewAPIError(errs.SubtypeUnknown, "draft not found").WithCode(230001),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "not_found without account-level code → recoverable",
|
||||
err: errs.NewAPIError(errs.SubtypeNotFound, "draft not found").WithCode(230002),
|
||||
name: "generic api_error → recoverable",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: 230001},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
func mailValidationError(format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
func mailValidationParamError(param, format string, args ...any) *errs.ValidationError {
|
||||
return mailValidationError(format, args...).WithParam(param)
|
||||
}
|
||||
|
||||
func mailInvalidParam(name, reason string) errs.InvalidParam {
|
||||
return errs.InvalidParam{Name: name, Reason: reason}
|
||||
}
|
||||
|
||||
func mailFailedPreconditionError(format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...)
|
||||
}
|
||||
|
||||
func mailInvalidResponseError(format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
|
||||
}
|
||||
|
||||
func mailFileIOError(format string, err error, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
func mailInputStatError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return mailValidationError("unsafe file path: %s", err).WithCause(err)
|
||||
}
|
||||
return mailValidationError("cannot read file: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
func mailDecorateProblemMessage(err error, format string, args ...any) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
prefix := fmt.Sprintf(format, args...)
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(prefix) != "" {
|
||||
p.Message = prefix + ": " + p.Message
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %s", prefix, err.Error()).WithCause(err)
|
||||
}
|
||||
|
||||
func mailAppendProblemHint(err error, hint string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
p.Hint = p.Hint + "; " + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "%s", err.Error()).WithHint("%s", hint).WithCause(err)
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
func TestMailFileIOErrorTyped(t *testing.T) {
|
||||
cause := errors.New("disk read failed")
|
||||
|
||||
err := mailFileIOError("load %s: %v", cause, "body.html", cause)
|
||||
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("expected internal error, got %T", err)
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("cause not preserved: %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeFileIO {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeFileIO)
|
||||
}
|
||||
if !strings.Contains(p.Message, "body.html") || !strings.Contains(p.Message, "disk read failed") {
|
||||
t.Fatalf("message missing context: %q", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailFileIOErrorDoesNotAppendCauseAsFormatArg(t *testing.T) {
|
||||
cause := errors.New("mkdir denied")
|
||||
|
||||
err := mailFileIOError("cannot create output directory %q", cause, "out")
|
||||
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("cause not preserved: %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if strings.Contains(p.Message, "%!(") {
|
||||
t.Fatalf("message contains fmt extra marker: %q", p.Message)
|
||||
}
|
||||
if strings.Contains(p.Message, "mkdir denied") {
|
||||
t.Fatalf("cause should not be implicitly appended to message: %q", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailInputStatErrorTyped(t *testing.T) {
|
||||
if err := mailInputStatError(nil); err != nil {
|
||||
t.Fatalf("nil input should stay nil, got %v", err)
|
||||
}
|
||||
|
||||
pathErr := fileio.ErrPathValidation
|
||||
err := mailInputStatError(pathErr)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T", err)
|
||||
}
|
||||
if !errors.Is(err, pathErr) {
|
||||
t.Fatalf("cause not preserved: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsafe file path") {
|
||||
t.Fatalf("unexpected path validation message: %v", err)
|
||||
}
|
||||
|
||||
statErr := errors.New("permission denied")
|
||||
err = mailInputStatError(statErr)
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T", err)
|
||||
}
|
||||
if !errors.Is(err, statErr) {
|
||||
t.Fatalf("stat cause not preserved: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Fatalf("unexpected stat message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailDecorateProblemMessageTypedAndPlain(t *testing.T) {
|
||||
if err := mailDecorateProblemMessage(nil, "fetch profile"); err != nil {
|
||||
t.Fatalf("nil input should stay nil, got %v", err)
|
||||
}
|
||||
|
||||
typedErr := errs.NewAPIError(errs.SubtypeRateLimit, "too many requests")
|
||||
err := mailDecorateProblemMessage(typedErr, "fetch %s", "profile")
|
||||
if err != typedErr {
|
||||
t.Fatalf("typed error should be decorated in place")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Message != "fetch profile: too many requests" {
|
||||
t.Fatalf("message = %q", p.Message)
|
||||
}
|
||||
|
||||
blankPrefixErr := errs.NewAPIError(errs.SubtypeUnknown, "unchanged")
|
||||
err = mailDecorateProblemMessage(blankPrefixErr, " ")
|
||||
p, ok = errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Message != "unchanged" {
|
||||
t.Fatalf("blank prefix should not change message, got %q", p.Message)
|
||||
}
|
||||
|
||||
plainCause := errors.New("sdk failed")
|
||||
err = mailDecorateProblemMessage(plainCause, "fetch mailbox")
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("plain error should be upgraded to internal SDK error, got %T", err)
|
||||
}
|
||||
if !errors.Is(err, plainCause) {
|
||||
t.Fatalf("cause not preserved: %v", err)
|
||||
}
|
||||
p, ok = errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeSDKError || !strings.Contains(p.Message, "fetch mailbox: sdk failed") {
|
||||
t.Fatalf("unexpected problem: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailAppendProblemHintTypedAndPlain(t *testing.T) {
|
||||
if err := mailAppendProblemHint(nil, "retry later"); err != nil {
|
||||
t.Fatalf("nil input should stay nil, got %v", err)
|
||||
}
|
||||
|
||||
withoutHint := errs.NewAPIError(errs.SubtypeUnknown, "failed")
|
||||
err := mailAppendProblemHint(withoutHint, "retry later")
|
||||
if err != withoutHint {
|
||||
t.Fatalf("typed error should be updated in place")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Hint != "retry later" {
|
||||
t.Fatalf("hint = %q", p.Hint)
|
||||
}
|
||||
|
||||
withHint := errs.NewAPIError(errs.SubtypeUnknown, "failed").WithHint("check scope")
|
||||
err = mailAppendProblemHint(withHint, "retry later")
|
||||
p, ok = errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Hint != "check scope; retry later" {
|
||||
t.Fatalf("hint = %q", p.Hint)
|
||||
}
|
||||
|
||||
plainCause := errors.New("legacy api failed")
|
||||
err = mailAppendProblemHint(plainCause, "retry later")
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("plain error should be upgraded to API error, got %T", err)
|
||||
}
|
||||
if !errors.Is(err, plainCause) {
|
||||
t.Fatalf("cause not preserved: %v", err)
|
||||
}
|
||||
p, ok = errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Hint != "retry later" || p.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("unexpected problem: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBodyFileMutexTypedErrors(t *testing.T) {
|
||||
err := validateBodyFileMutex("<p>Hello</p>", "body.html", func(string) error { return nil })
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T", err)
|
||||
}
|
||||
if len(validationErr.Params) != 2 {
|
||||
t.Fatalf("params = %#v, want two conflicting params", validationErr.Params)
|
||||
}
|
||||
if validationErr.Params[0].Name != "--body" || validationErr.Params[1].Name != "--body-file" {
|
||||
t.Fatalf("unexpected params: %#v", validationErr.Params)
|
||||
}
|
||||
|
||||
pathErr := errors.New("outside cwd")
|
||||
err = validateBodyFileMutex("", "body.html", func(string) error { return pathErr })
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T", err)
|
||||
}
|
||||
if validationErr.Param != "--body-file" {
|
||||
t.Fatalf("param = %q, want --body-file", validationErr.Param)
|
||||
}
|
||||
if !errors.Is(err, pathErr) {
|
||||
t.Fatalf("cause not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadBodyFileTypedErrors(t *testing.T) {
|
||||
openErr := errors.New("missing")
|
||||
_, err := readBodyFile(bodyFileTestIO{
|
||||
open: func(string) (fileio.File, error) { return nil, openErr },
|
||||
}, "missing.html")
|
||||
requireBodyFileValidationError(t, err, openErr)
|
||||
if !strings.Contains(err.Error(), "open --body-file missing.html") {
|
||||
t.Fatalf("unexpected open message: %v", err)
|
||||
}
|
||||
|
||||
readErr := errors.New("read broken")
|
||||
_, err = readBodyFile(bodyFileTestIO{
|
||||
open: func(string) (fileio.File, error) {
|
||||
return &bodyFileTestFile{readErr: readErr}, nil
|
||||
},
|
||||
}, "body.html")
|
||||
requireBodyFileValidationError(t, err, readErr)
|
||||
if !strings.Contains(err.Error(), "read --body-file body.html") {
|
||||
t.Fatalf("unexpected read message: %v", err)
|
||||
}
|
||||
|
||||
_, err = readBodyFile(bodyFileTestIO{
|
||||
open: func(string) (fileio.File, error) {
|
||||
return &bodyFileTestFile{remaining: maxBodyFileSize + 1}, nil
|
||||
},
|
||||
}, "huge.html")
|
||||
requireBodyFileValidationError(t, err, nil)
|
||||
if !strings.Contains(err.Error(), "file exceeds 32 MB limit") {
|
||||
t.Fatalf("unexpected size message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireBodyFileValidationError(t *testing.T, err error, cause error) {
|
||||
t.Helper()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T (%v)", err, err)
|
||||
}
|
||||
if validationErr.Param != "--body-file" {
|
||||
t.Fatalf("param = %q, want --body-file", validationErr.Param)
|
||||
}
|
||||
if cause != nil && !errors.Is(err, cause) {
|
||||
t.Fatalf("cause %v not preserved in %v", cause, err)
|
||||
}
|
||||
}
|
||||
|
||||
type bodyFileTestIO struct {
|
||||
open func(string) (fileio.File, error)
|
||||
}
|
||||
|
||||
func (fio bodyFileTestIO) Open(name string) (fileio.File, error) {
|
||||
return fio.open(name)
|
||||
}
|
||||
|
||||
func (fio bodyFileTestIO) Stat(string) (fileio.FileInfo, error) {
|
||||
return nil, errors.New("unused")
|
||||
}
|
||||
|
||||
func (fio bodyFileTestIO) ResolvePath(path string) (string, error) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (fio bodyFileTestIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
|
||||
return nil, errors.New("unused")
|
||||
}
|
||||
|
||||
type bodyFileTestFile struct {
|
||||
readErr error
|
||||
remaining int
|
||||
}
|
||||
|
||||
func (f *bodyFileTestFile) Read(p []byte) (int, error) {
|
||||
if f.readErr != nil {
|
||||
return 0, f.readErr
|
||||
}
|
||||
if f.remaining <= 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := len(p)
|
||||
if n > f.remaining {
|
||||
n = f.remaining
|
||||
}
|
||||
for i := range p[:n] {
|
||||
p[i] = 'x'
|
||||
}
|
||||
f.remaining -= n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (f *bodyFileTestFile) ReadAt([]byte, int64) (int, error) {
|
||||
return 0, errors.New("unused")
|
||||
}
|
||||
|
||||
func (f *bodyFileTestFile) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ fileio.FileIO = bodyFileTestIO{}
|
||||
var _ fileio.File = (*bodyFileTestFile)(nil)
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
@@ -135,10 +135,10 @@ var MailForward = common.Shortcut{
|
||||
}
|
||||
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to fetch original message")
|
||||
return fmt.Errorf("failed to fetch original message: %w", err)
|
||||
}
|
||||
if err := validateForwardAttachmentURLs(sourceMsg); err != nil {
|
||||
return mailDecorateProblemMessage(err, "forward blocked")
|
||||
return fmt.Errorf("forward blocked: %w", err)
|
||||
}
|
||||
orig := sourceMsg.Original
|
||||
|
||||
@@ -243,7 +243,7 @@ var MailForward = common.Shortcut{
|
||||
}
|
||||
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
|
||||
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
|
||||
return mailValidationParamError("--inline", "--inline requires HTML mode, but neither the new body nor the original message contains HTML")
|
||||
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
|
||||
}
|
||||
inlineSpecs, err := parseInlineSpecs(inlineFlag)
|
||||
if err != nil {
|
||||
@@ -257,7 +257,7 @@ var MailForward = common.Shortcut{
|
||||
lintApplied, lintBlocked := emptyLintEnvelopeFields()
|
||||
if useHTML {
|
||||
if err := validateInlineImageURLs(sourceMsg); err != nil {
|
||||
return mailDecorateProblemMessage(err, "forward blocked")
|
||||
return fmt.Errorf("forward blocked: %w", err)
|
||||
}
|
||||
processedBody := buildBodyDiv(body, bodyIsHTML(body))
|
||||
origLargeAttCard := stripLargeAttachmentCard(&orig)
|
||||
@@ -274,7 +274,7 @@ var MailForward = common.Shortcut{
|
||||
}
|
||||
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(processedBody)
|
||||
if resolveErr != nil {
|
||||
return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
|
||||
return resolveErr
|
||||
}
|
||||
bodyWithSig := resolved
|
||||
if sigResult != nil {
|
||||
@@ -347,7 +347,7 @@ var MailForward = common.Shortcut{
|
||||
}
|
||||
content, err := downloadAttachmentContent(runtime, att.DownloadURL)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to download original attachment %s", att.Filename)
|
||||
return fmt.Errorf("failed to download original attachment %s: %w", att.Filename, err)
|
||||
}
|
||||
contentType := att.ContentType
|
||||
if contentType == "" {
|
||||
@@ -381,13 +381,13 @@ var MailForward = common.Shortcut{
|
||||
}
|
||||
for _, f := range userFiles {
|
||||
if f.Size > MaxLargeAttachmentSize {
|
||||
return mailFailedPreconditionError("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
|
||||
return output.ErrValidation("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
|
||||
f.FileName, float64(f.Size)/1024/1024/1024, float64(MaxLargeAttachmentSize)/1024/1024/1024)
|
||||
}
|
||||
}
|
||||
totalCount := len(origAtts) + len(largeAttIDs) + len(userFiles)
|
||||
if totalCount > MaxAttachmentCount {
|
||||
return mailFailedPreconditionError("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount)
|
||||
return output.ErrValidation("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount)
|
||||
}
|
||||
allFiles = append(allFiles, userFiles...)
|
||||
classified := classifyAttachments(allFiles, emlBase)
|
||||
@@ -413,7 +413,7 @@ var MailForward = common.Shortcut{
|
||||
// Upload oversized attachments as large attachments.
|
||||
if len(classified.Oversized) > 0 {
|
||||
if composedHTMLBody == "" && composedTextBody == "" {
|
||||
return mailFailedPreconditionError("large attachments require a body; " +
|
||||
return output.ErrValidation("large attachments require a body; " +
|
||||
"empty messages cannot include the download link")
|
||||
}
|
||||
if runtime.Config == nil || runtime.UserOpenId() == "" {
|
||||
@@ -421,7 +421,7 @@ var MailForward = common.Shortcut{
|
||||
for _, f := range classified.Oversized {
|
||||
totalBytes += f.Size
|
||||
}
|
||||
return mailFailedPreconditionError("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
|
||||
return output.ErrValidation("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
|
||||
"large attachment upload requires user identity (--as user)",
|
||||
float64(totalBytes)/1024/1024)
|
||||
}
|
||||
@@ -486,18 +486,18 @@ var MailForward = common.Shortcut{
|
||||
if len(mergedLargeAttIDs) > 0 {
|
||||
idsJSON, err := json.Marshal(mergedLargeAttIDs)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to encode large attachment IDs: %v", err).WithCause(err)
|
||||
return fmt.Errorf("failed to encode large attachment IDs: %w", err)
|
||||
}
|
||||
bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, base64.StdEncoding.EncodeToString(idsJSON))
|
||||
}
|
||||
rawEML, err := bld.BuildBase64URL()
|
||||
if err != nil {
|
||||
return mailValidationError("failed to build EML: %v", err).WithCause(err)
|
||||
return fmt.Errorf("failed to build EML: %w", err)
|
||||
}
|
||||
|
||||
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to create draft")
|
||||
return fmt.Errorf("failed to create draft: %w", err)
|
||||
}
|
||||
showLintDetails := runtime.Bool("show-lint-details")
|
||||
if !confirmSend {
|
||||
@@ -510,7 +510,7 @@ var MailForward = common.Shortcut{
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to send forward (draft %s created but not sent)", draftResult.DraftID)
|
||||
return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftResult.DraftID, err)
|
||||
}
|
||||
out := buildDraftSendOutput(resData, mailboxID)
|
||||
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/mail/lint"
|
||||
@@ -55,18 +54,10 @@ var MailLintHTML = common.Shortcut{
|
||||
// Mutual exclusion + exactly-one-of validation for --body / --body-file.
|
||||
bodyEmpty := strings.TrimSpace(body) == ""
|
||||
if bodyEmpty && bodyFile == "" {
|
||||
return mailValidationError("exactly one of --body or --body-file is required").
|
||||
WithParams(
|
||||
mailInvalidParam("--body", "required when --body-file is empty"),
|
||||
mailInvalidParam("--body-file", "required when --body is empty"),
|
||||
)
|
||||
return output.ErrValidation("exactly one of --body or --body-file is required")
|
||||
}
|
||||
if !bodyEmpty && bodyFile != "" {
|
||||
return mailValidationError("--body and --body-file are mutually exclusive; pass exactly one").
|
||||
WithParams(
|
||||
mailInvalidParam("--body", "mutually exclusive with --body-file"),
|
||||
mailInvalidParam("--body-file", "mutually exclusive with --body"),
|
||||
)
|
||||
return output.ErrValidation("--body and --body-file are mutually exclusive; pass exactly one")
|
||||
}
|
||||
|
||||
// --body-file safety: cwd-subtree only. Mirrors the existing pattern
|
||||
@@ -74,7 +65,7 @@ var MailLintHTML = common.Shortcut{
|
||||
// runtime.ValidatePath.
|
||||
if bodyFile != "" {
|
||||
if err := runtime.ValidatePath(bodyFile); err != nil {
|
||||
return mailValidationParamError("--body-file", "--body-file: %v", err).WithCause(err)
|
||||
return output.ErrValidation("--body-file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +141,7 @@ func readLintHTMLBody(runtime *common.RuntimeContext) (string, error) {
|
||||
path := strings.TrimSpace(runtime.Str("body-file"))
|
||||
if path == "" {
|
||||
// Should be unreachable given Validate, but defensive.
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "internal: --body-file empty after Validate")
|
||||
return "", output.ErrValidation("internal: --body-file empty after Validate")
|
||||
}
|
||||
return readBodyFile(runtime.FileIO(), path)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -47,7 +48,7 @@ var MailMessage = common.Shortcut{
|
||||
|
||||
msg, err := fetchFullMessage(runtime, mailboxID, messageID, html)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to fetch email")
|
||||
return fmt.Errorf("failed to fetch email: %w", err)
|
||||
}
|
||||
|
||||
out := buildMessageOutput(msg, html)
|
||||
|
||||
@@ -137,7 +137,7 @@ var MailReply = common.Shortcut{
|
||||
}
|
||||
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to fetch original message")
|
||||
return fmt.Errorf("failed to fetch original message: %w", err)
|
||||
}
|
||||
orig := sourceMsg.Original
|
||||
stripLargeAttachmentCard(&orig)
|
||||
@@ -213,7 +213,7 @@ var MailReply = common.Shortcut{
|
||||
|
||||
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
|
||||
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
|
||||
return mailValidationParamError("--inline", "--inline requires HTML mode, but neither the new body nor the original message contains HTML")
|
||||
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
|
||||
}
|
||||
var bodyStr string
|
||||
if useHTML {
|
||||
@@ -264,7 +264,7 @@ var MailReply = common.Shortcut{
|
||||
lintApplied, lintBlocked := emptyLintEnvelopeFields()
|
||||
if useHTML {
|
||||
if err := validateInlineImageURLs(sourceMsg); err != nil {
|
||||
return mailDecorateProblemMessage(err, "HTML reply blocked")
|
||||
return fmt.Errorf("HTML reply blocked: %w", err)
|
||||
}
|
||||
var srcCIDs []string
|
||||
bld, srcCIDs, srcInlineBytes, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
|
||||
@@ -273,7 +273,7 @@ var MailReply = common.Shortcut{
|
||||
}
|
||||
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr)
|
||||
if resolveErr != nil {
|
||||
return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
|
||||
return resolveErr
|
||||
}
|
||||
bodyWithSig := resolved
|
||||
if sigResult != nil {
|
||||
@@ -336,12 +336,12 @@ var MailReply = common.Shortcut{
|
||||
}
|
||||
rawEML, err := bld.BuildBase64URL()
|
||||
if err != nil {
|
||||
return mailValidationError("failed to build EML: %v", err).WithCause(err)
|
||||
return fmt.Errorf("failed to build EML: %w", err)
|
||||
}
|
||||
|
||||
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to create draft")
|
||||
return fmt.Errorf("failed to create draft: %w", err)
|
||||
}
|
||||
showLintDetails := runtime.Bool("show-lint-details")
|
||||
if !confirmSend {
|
||||
@@ -354,7 +354,7 @@ var MailReply = common.Shortcut{
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to send reply (draft %s created but not sent)", draftResult.DraftID)
|
||||
return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftResult.DraftID, err)
|
||||
}
|
||||
out := buildDraftSendOutput(resData, mailboxID)
|
||||
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
|
||||
|
||||
@@ -139,7 +139,7 @@ var MailReplyAll = common.Shortcut{
|
||||
}
|
||||
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to fetch original message")
|
||||
return fmt.Errorf("failed to fetch original message: %w", err)
|
||||
}
|
||||
orig := sourceMsg.Original
|
||||
stripLargeAttachmentCard(&orig)
|
||||
@@ -226,7 +226,7 @@ var MailReplyAll = common.Shortcut{
|
||||
|
||||
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
|
||||
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
|
||||
return mailValidationParamError("--inline", "--inline requires HTML mode, but neither the new body nor the original message contains HTML")
|
||||
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
|
||||
}
|
||||
var bodyStr string
|
||||
if useHTML {
|
||||
@@ -271,7 +271,7 @@ var MailReplyAll = common.Shortcut{
|
||||
lintApplied, lintBlocked := emptyLintEnvelopeFields()
|
||||
if useHTML {
|
||||
if err := validateInlineImageURLs(sourceMsg); err != nil {
|
||||
return mailDecorateProblemMessage(err, "HTML reply-all blocked")
|
||||
return fmt.Errorf("HTML reply-all blocked: %w", err)
|
||||
}
|
||||
var srcCIDs []string
|
||||
bld, srcCIDs, srcInlineBytes, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
|
||||
@@ -280,7 +280,7 @@ var MailReplyAll = common.Shortcut{
|
||||
}
|
||||
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr)
|
||||
if resolveErr != nil {
|
||||
return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
|
||||
return resolveErr
|
||||
}
|
||||
bodyWithSig := resolved
|
||||
if sigResult != nil {
|
||||
@@ -341,12 +341,12 @@ var MailReplyAll = common.Shortcut{
|
||||
}
|
||||
rawEML, err := bld.BuildBase64URL()
|
||||
if err != nil {
|
||||
return mailValidationError("failed to build EML: %v", err).WithCause(err)
|
||||
return fmt.Errorf("failed to build EML: %w", err)
|
||||
}
|
||||
|
||||
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to create draft")
|
||||
return fmt.Errorf("failed to create draft: %w", err)
|
||||
}
|
||||
showLintDetails := runtime.Bool("show-lint-details")
|
||||
if !confirmSend {
|
||||
@@ -359,7 +359,7 @@ var MailReplyAll = common.Shortcut{
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to send reply-all (draft %s created but not sent)", draftResult.DraftID)
|
||||
return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftResult.DraftID, err)
|
||||
}
|
||||
out := buildDraftSendOutput(resData, mailboxID)
|
||||
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
@@ -82,7 +83,7 @@ var MailSend = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
|
||||
return mailValidationParamError("--subject", "--subject is required; pass the final email subject (or use --template-id)")
|
||||
return output.ErrValidation("--subject is required; pass the final email subject (or use --template-id)")
|
||||
}
|
||||
// With --template-id, tos/ccs/bccs may come from the template, so
|
||||
// defer the at-least-one-recipient check to Execute (after
|
||||
@@ -240,7 +241,7 @@ var MailSend = common.Shortcut{
|
||||
}
|
||||
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
|
||||
if resolveErr != nil {
|
||||
return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
|
||||
return resolveErr
|
||||
}
|
||||
resolved = injectSignatureIntoBody(resolved, sigResult)
|
||||
// Writing-path lint: AutoFix=true / Strict=false — the writing-path
|
||||
@@ -307,12 +308,12 @@ var MailSend = common.Shortcut{
|
||||
|
||||
rawEML, err := bld.BuildBase64URL()
|
||||
if err != nil {
|
||||
return mailValidationError("failed to build EML: %v", err).WithCause(err)
|
||||
return fmt.Errorf("failed to build EML: %w", err)
|
||||
}
|
||||
|
||||
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to create draft")
|
||||
return fmt.Errorf("failed to create draft: %w", err)
|
||||
}
|
||||
showLintDetails := runtime.Bool("show-lint-details")
|
||||
if !confirmSend {
|
||||
@@ -325,7 +326,7 @@ var MailSend = common.Shortcut{
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to send email (draft %s created but not sent)", draftResult.DraftID)
|
||||
return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftResult.DraftID, err)
|
||||
}
|
||||
out := buildDraftSendOutput(resData, mailboxID)
|
||||
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
|
||||
|
||||
@@ -113,11 +113,10 @@ var MailSendReceipt = common.Shortcut{
|
||||
|
||||
msg, err := fetchFullMessage(runtime, mailboxID, messageID, false)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to fetch original message")
|
||||
return fmt.Errorf("failed to fetch original message: %w", err)
|
||||
}
|
||||
if !hasReadReceiptRequestLabel(msg) {
|
||||
return mailFailedPreconditionError("message %s did not request a read receipt (no %s label); refusing to send receipt", messageID, readReceiptRequestLabel).
|
||||
WithHint("only run +send-receipt for incoming messages that carry the READ_RECEIPT_REQUEST label")
|
||||
return fmt.Errorf("message %s did not request a read receipt (no %s label); refusing to send receipt", messageID, readReceiptRequestLabel)
|
||||
}
|
||||
|
||||
origSubject := strVal(msg["subject"])
|
||||
@@ -127,12 +126,12 @@ var MailSendReceipt = common.Shortcut{
|
||||
origSendMillis := parseInternalDateMillis(msg["internal_date"])
|
||||
|
||||
if origFromEmail == "" {
|
||||
return mailFailedPreconditionError("original message %s has no sender address; cannot address receipt", messageID)
|
||||
return fmt.Errorf("original message %s has no sender address; cannot address receipt", messageID)
|
||||
}
|
||||
|
||||
senderEmail := resolveComposeSenderEmail(runtime)
|
||||
if senderEmail == "" {
|
||||
return mailValidationParamError("--from", "unable to determine sender email; please specify --from explicitly")
|
||||
return fmt.Errorf("unable to determine sender email; please specify --from explicitly")
|
||||
}
|
||||
|
||||
lang := detectSubjectLang(origSubject)
|
||||
@@ -159,16 +158,16 @@ var MailSendReceipt = common.Shortcut{
|
||||
|
||||
rawEML, err := bld.BuildBase64URL()
|
||||
if err != nil {
|
||||
return mailValidationError("failed to build receipt EML: %v", err).WithCause(err)
|
||||
return fmt.Errorf("failed to build receipt EML: %w", err)
|
||||
}
|
||||
|
||||
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to create receipt draft")
|
||||
return fmt.Errorf("failed to create receipt draft: %w", err)
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, "")
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to send receipt (draft %s created but not sent)", draftResult.DraftID)
|
||||
return fmt.Errorf("failed to send receipt (draft %s created but not sent): %w", draftResult.DraftID, err)
|
||||
}
|
||||
|
||||
out := buildDraftSendOutput(resData, mailboxID)
|
||||
|
||||
@@ -5,7 +5,9 @@ package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -64,22 +66,14 @@ var MailShareToChat = common.Shortcut{
|
||||
msgID := runtime.Str("message-id")
|
||||
threadID := runtime.Str("thread-id")
|
||||
if msgID == "" && threadID == "" {
|
||||
return mailValidationError("either --message-id or --thread-id is required").
|
||||
WithParams(
|
||||
mailInvalidParam("--message-id", "required when --thread-id is empty"),
|
||||
mailInvalidParam("--thread-id", "required when --message-id is empty"),
|
||||
)
|
||||
return output.ErrValidation("either --message-id or --thread-id is required")
|
||||
}
|
||||
if msgID != "" && threadID != "" {
|
||||
return mailValidationError("--message-id and --thread-id are mutually exclusive").
|
||||
WithParams(
|
||||
mailInvalidParam("--message-id", "mutually exclusive with --thread-id"),
|
||||
mailInvalidParam("--thread-id", "mutually exclusive with --message-id"),
|
||||
)
|
||||
return output.ErrValidation("--message-id and --thread-id are mutually exclusive")
|
||||
}
|
||||
idType := runtime.Str("receive-id-type")
|
||||
if !validReceiveIDTypes[idType] {
|
||||
return mailValidationParamError("--receive-id-type", "--receive-id-type must be one of: chat_id, open_id, user_id, union_id, email")
|
||||
return output.ErrValidation("--receive-id-type must be one of: chat_id, open_id, user_id, union_id, email")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -96,23 +90,23 @@ var MailShareToChat = common.Shortcut{
|
||||
} else {
|
||||
createBody = map[string]interface{}{"message_id": msgID}
|
||||
}
|
||||
createResp, err := runtime.CallAPITyped("POST",
|
||||
createResp, err := runtime.CallAPI("POST",
|
||||
mailboxPath(mailboxID, "messages", "share_token"),
|
||||
nil, createBody)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "create share token")
|
||||
return fmt.Errorf("create share token: %w", err)
|
||||
}
|
||||
cardID, _ := createResp["card_id"].(string)
|
||||
if cardID == "" {
|
||||
return mailInvalidResponseError("create share token: response missing card_id")
|
||||
return fmt.Errorf("create share token: response missing card_id")
|
||||
}
|
||||
|
||||
sendResp, err := runtime.CallAPITyped("POST",
|
||||
sendResp, err := runtime.CallAPI("POST",
|
||||
mailboxPath(mailboxID, "share_tokens", cardID, "send"),
|
||||
map[string]interface{}{"receive_id_type": receiveIDType},
|
||||
map[string]interface{}{"receive_id": receiveID})
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "share token created (card_id=%s) but send failed", cardID)
|
||||
return fmt.Errorf("share token created (card_id=%s) but send failed: %w", cardID, err)
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
|
||||
@@ -16,13 +16,16 @@ import (
|
||||
|
||||
// assertValidationError fails the test unless err carries the validation
|
||||
// category with ExitValidation exit code and a message containing wantSubstr.
|
||||
// Mail-produced validation errors should be typed; the exit-code fallback keeps
|
||||
// shared framework validation gates covered without asserting their shape here.
|
||||
// Accepts both typed *errs.ValidationError and legacy *output.ExitError so
|
||||
// the helper survives the error-contract migration.
|
||||
func assertValidationError(t *testing.T, err error, wantSubstr string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error, got nil")
|
||||
}
|
||||
// Accept both typed *errs.ValidationError and legacy *output.ExitError —
|
||||
// the helper's purpose is to assert "this is a validation-category
|
||||
// error" via either contract, so the dual-path matches the docstring.
|
||||
code := output.ExitCodeOf(err)
|
||||
if !errs.IsValidation(err) && code != output.ExitValidation {
|
||||
t.Fatalf("expected a validation-category error, got %T: %v", err, err)
|
||||
@@ -187,10 +190,6 @@ func validMessageIDForTest(s string) string {
|
||||
return base64.URLEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
func rawMessageIDForTest(s string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsAcceptsValidIDs(t *testing.T) {
|
||||
_, err := validateMessageIDs(validMessageIDForTest("biz-001") + "," + validMessageIDForTest("biz-002"))
|
||||
if err != nil {
|
||||
@@ -198,13 +197,6 @@ func TestValidateMessageIDsAcceptsValidIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsAcceptsRawBase64URLIDs(t *testing.T) {
|
||||
_, err := validateMessageIDs(rawMessageIDForTest("biz-raw-001"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for raw base64url ID, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsRejectsEmpty(t *testing.T) {
|
||||
_, err := validateMessageIDs("")
|
||||
assertValidationError(t, err, "--message-ids is required")
|
||||
|
||||
@@ -110,7 +110,7 @@ func executeSignatureDetail(runtime *common.RuntimeContext, resp *signature.GetS
|
||||
}
|
||||
}
|
||||
if sig == nil {
|
||||
return mailValidationParamError("--detail", "signature not found: %s", sigID)
|
||||
return output.ErrValidation("signature not found: %s", sigID)
|
||||
}
|
||||
|
||||
lang := resolveLang(runtime)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -78,17 +79,13 @@ var MailTemplateCreate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return mailValidationParamError("--name", "--name is required")
|
||||
return output.ErrValidation("--name is required")
|
||||
}
|
||||
if len([]rune(runtime.Str("name"))) > 100 {
|
||||
return mailValidationParamError("--name", "--name must be at most 100 characters")
|
||||
return output.ErrValidation("--name must be at most 100 characters")
|
||||
}
|
||||
if runtime.Str("template-content") != "" && runtime.Str("template-content-file") != "" {
|
||||
return mailValidationError("--template-content and --template-content-file are mutually exclusive").
|
||||
WithParams(
|
||||
mailInvalidParam("--template-content", "mutually exclusive with --template-content-file"),
|
||||
mailInvalidParam("--template-content-file", "mutually exclusive with --template-content"),
|
||||
)
|
||||
return output.ErrValidation("--template-content and --template-content-file are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -107,7 +104,7 @@ var MailTemplateCreate = common.Shortcut{
|
||||
|
||||
content = wrapTemplateContentIfNeeded(content, isPlainText)
|
||||
if int64(len(content)) > maxTemplateContentBytes {
|
||||
return mailFailedPreconditionError("template content exceeds %d MB (got %.1f MB)",
|
||||
return output.ErrValidation("template content exceeds %d MB (got %.1f MB)",
|
||||
maxTemplateContentBytes/(1024*1024),
|
||||
float64(len(content))/1024/1024)
|
||||
}
|
||||
@@ -145,7 +142,7 @@ var MailTemplateCreate = common.Shortcut{
|
||||
|
||||
resp, err := createTemplate(runtime, mailboxID, payload)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "create template failed")
|
||||
return fmt.Errorf("create template failed: %w", err)
|
||||
}
|
||||
tpl, _ := extractTemplatePayload(resp)
|
||||
out := map[string]interface{}{
|
||||
@@ -176,12 +173,12 @@ func resolveTemplateContent(runtime *common.RuntimeContext) (content, sourcePath
|
||||
}
|
||||
f, err := runtime.FileIO().Open(path)
|
||||
if err != nil {
|
||||
return "", path, mailValidationParamError("--template-content-file", "open --template-content-file %s: %v", path, err).WithCause(mailInputStatError(err))
|
||||
return "", path, output.ErrValidation("open --template-content-file %s: %v", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
buf, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", path, mailValidationParamError("--template-content-file", "read --template-content-file %s: %v", path, err).WithCause(err)
|
||||
return "", path, output.ErrValidation("read --template-content-file %s: %v", path, err)
|
||||
}
|
||||
return string(buf), path, nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -92,17 +93,13 @@ var MailTemplateUpdate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if runtime.Str("template-id") == "" {
|
||||
return mailValidationParamError("--template-id", "--template-id is required (or use --print-patch-template to print the patch skeleton)")
|
||||
return output.ErrValidation("--template-id is required (or use --print-patch-template to print the patch skeleton)")
|
||||
}
|
||||
if runtime.Str("set-template-content") != "" && runtime.Str("set-template-content-file") != "" {
|
||||
return mailValidationError("--set-template-content and --set-template-content-file are mutually exclusive").
|
||||
WithParams(
|
||||
mailInvalidParam("--set-template-content", "mutually exclusive with --set-template-content-file"),
|
||||
mailInvalidParam("--set-template-content-file", "mutually exclusive with --set-template-content"),
|
||||
)
|
||||
return output.ErrValidation("--set-template-content and --set-template-content-file are mutually exclusive")
|
||||
}
|
||||
if name := runtime.Str("set-name"); name != "" && len([]rune(name)) > 100 {
|
||||
return mailValidationParamError("--set-name", "--set-name must be at most 100 characters")
|
||||
return output.ErrValidation("--set-name must be at most 100 characters")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -174,16 +171,16 @@ var MailTemplateUpdate = common.Shortcut{
|
||||
if pf := strings.TrimSpace(runtime.Str("patch-file")); pf != "" {
|
||||
f, err := runtime.FileIO().Open(pf)
|
||||
if err != nil {
|
||||
return mailValidationParamError("--patch-file", "open --patch-file %s: %v", pf, err).WithCause(mailInputStatError(err))
|
||||
return output.ErrValidation("open --patch-file %s: %v", pf, err)
|
||||
}
|
||||
buf, readErr := io.ReadAll(f)
|
||||
f.Close()
|
||||
if readErr != nil {
|
||||
return mailValidationParamError("--patch-file", "read --patch-file %s: %v", pf, readErr).WithCause(readErr)
|
||||
return output.ErrValidation("read --patch-file %s: %v", pf, readErr)
|
||||
}
|
||||
var patch templatePatchFile
|
||||
if err := json.Unmarshal(buf, &patch); err != nil {
|
||||
return mailValidationParamError("--patch-file", "parse --patch-file %s: %v", pf, err).WithCause(err)
|
||||
return output.ErrValidation("parse --patch-file %s: %v", pf, err)
|
||||
}
|
||||
if patch.TemplateContent != nil {
|
||||
contentChanged = true
|
||||
@@ -201,7 +198,7 @@ var MailTemplateUpdate = common.Shortcut{
|
||||
tpl.TemplateContent = wrapTemplateContentIfNeeded(tpl.TemplateContent, tpl.IsPlainTextMode)
|
||||
}
|
||||
if int64(len(tpl.TemplateContent)) > maxTemplateContentBytes {
|
||||
return mailFailedPreconditionError("template content exceeds %d MB (got %.1f MB)",
|
||||
return output.ErrValidation("template content exceeds %d MB (got %.1f MB)",
|
||||
maxTemplateContentBytes/(1024*1024),
|
||||
float64(len(tpl.TemplateContent))/1024/1024)
|
||||
}
|
||||
@@ -281,7 +278,7 @@ var MailTemplateUpdate = common.Shortcut{
|
||||
|
||||
resp, err := updateTemplate(runtime, mailboxID, tid, tpl)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "update template failed")
|
||||
return fmt.Errorf("update template failed: %w", err)
|
||||
}
|
||||
updated, _ := extractTemplatePayload(resp)
|
||||
out := map[string]interface{}{
|
||||
@@ -315,12 +312,12 @@ func resolveTemplateUpdateContent(runtime *common.RuntimeContext) (content, sour
|
||||
}
|
||||
f, err := runtime.FileIO().Open(path)
|
||||
if err != nil {
|
||||
return "", path, mailValidationParamError("--set-template-content-file", "open --set-template-content-file %s: %v", path, err).WithCause(mailInputStatError(err))
|
||||
return "", path, output.ErrValidation("open --set-template-content-file %s: %v", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
buf, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", path, mailValidationParamError("--set-template-content-file", "read --set-template-content-file %s: %v", path, err).WithCause(err)
|
||||
return "", path, output.ErrValidation("read --set-template-content-file %s: %v", path, err)
|
||||
}
|
||||
return string(buf), path, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
@@ -87,9 +88,9 @@ var MailThread = common.Shortcut{
|
||||
if runtime.Bool("include-spam-trash") {
|
||||
params["include_spam_trash"] = true
|
||||
}
|
||||
listData, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "threads", threadID), params, nil)
|
||||
listData, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "threads", threadID), params, nil)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to get thread")
|
||||
return fmt.Errorf("failed to get thread: %w", err)
|
||||
}
|
||||
// New API: data.thread.messages[]; fallback to old API: data.items[].message
|
||||
var items []interface{}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -12,8 +13,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -141,7 +140,7 @@ var MailTriage = common.Shortcut{
|
||||
outFormat := runtime.Str("format")
|
||||
query := runtime.Str("query")
|
||||
if query != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--query", query); err != nil {
|
||||
if err := common.RejectDangerousChars("--query", query); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -266,18 +265,10 @@ var MailTriage = common.Shortcut{
|
||||
messages = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
// Inject mailbox_id into every message so downstream consumers
|
||||
// (e.g. mail +message) can preserve the mailbox context for
|
||||
// public/shared mailbox scenarios.
|
||||
for _, msg := range messages {
|
||||
msg["mailbox_id"] = mailbox
|
||||
}
|
||||
|
||||
switch outFormat {
|
||||
case "json", "data":
|
||||
outData := map[string]interface{}{
|
||||
"messages": messages,
|
||||
"mailbox_id": mailbox,
|
||||
"count": len(messages),
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
@@ -296,9 +287,6 @@ var MailTriage = common.Shortcut{
|
||||
"subject": sanitizeForTerminal(strVal(msg["subject"])),
|
||||
"message_id": msg["message_id"],
|
||||
}
|
||||
if mailbox != "me" {
|
||||
row["mailbox_id"] = mailbox
|
||||
}
|
||||
if showLabels {
|
||||
row["labels"] = msg["labels"]
|
||||
}
|
||||
@@ -309,9 +297,6 @@ var MailTriage = common.Shortcut{
|
||||
if hasMore && nextPageToken != "" {
|
||||
var hint strings.Builder
|
||||
hint.WriteString("next page: mail +triage")
|
||||
if mailbox != "me" {
|
||||
hint.WriteString(" --mailbox " + shellQuote(mailbox))
|
||||
}
|
||||
if query != "" {
|
||||
hint.WriteString(" --query " + shellQuote(query))
|
||||
}
|
||||
@@ -321,11 +306,7 @@ var MailTriage = common.Shortcut{
|
||||
hint.WriteString(" --page-token " + shellQuote(nextPageToken))
|
||||
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
|
||||
}
|
||||
if mailbox != "me" {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --mailbox "+shellQuote(mailbox)+" --message-id <id> to read full content")
|
||||
} else {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id <id> to read full content")
|
||||
}
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id <id> to read full content")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -422,9 +403,9 @@ func parseTriageFilter(filterStr string) (triageFilter, error) {
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&filter); err != nil {
|
||||
if hint := triageFilterUnknownFieldHint(err.Error()); hint != "" {
|
||||
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: %s", hint)
|
||||
return triageFilter{}, output.ErrValidation("invalid --filter: %s", hint)
|
||||
}
|
||||
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: %s", err)
|
||||
return triageFilter{}, output.ErrValidation("invalid --filter: %s", err)
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
@@ -943,16 +924,16 @@ func resolveTriagePath(parsed triagePageToken, query string, filter triageFilter
|
||||
switch parsed.Path {
|
||||
case "search":
|
||||
if !paramWantsSearch && (strings.TrimSpace(query) != "" || len(triageQueryFilterFields(filter)) > 0) {
|
||||
return false, mailValidationParamError("--page-token", "--page-token has search: prefix but current --query/--filter parameters indicate list path; remove conflicting parameters or use the correct token")
|
||||
return false, fmt.Errorf("--page-token has search: prefix but current --query/--filter parameters indicate list path; remove conflicting parameters or use the correct token")
|
||||
}
|
||||
return true, nil
|
||||
case "list":
|
||||
if paramWantsSearch {
|
||||
return false, mailValidationParamError("--page-token", "--page-token has list: prefix but --query or --filter contains search-only fields (e.g. from/to/subject); these parameters would be silently ignored; remove them or use a search: token")
|
||||
return false, fmt.Errorf("--page-token has list: prefix but --query or --filter contains search-only fields (e.g. from/to/subject); these parameters would be silently ignored — remove them or use a search: token")
|
||||
}
|
||||
return false, nil
|
||||
default:
|
||||
return false, mailValidationParamError("--page-token", "invalid --page-token: must start with 'search:' or 'list:' prefix (token was obtained from a previous mail +triage response)")
|
||||
return false, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix (token was obtained from a previous mail +triage response)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -979,15 +960,15 @@ func parseTriagePageToken(token string) (triagePageToken, error) {
|
||||
}
|
||||
idx := strings.IndexByte(token, ':')
|
||||
if idx < 0 {
|
||||
return triagePageToken{}, mailValidationParamError("--page-token", "invalid --page-token: must start with 'search:' or 'list:' prefix (token was obtained from a previous mail +triage response)")
|
||||
return triagePageToken{}, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix (token was obtained from a previous mail +triage response)")
|
||||
}
|
||||
path := token[:idx]
|
||||
raw := token[idx+1:]
|
||||
if path != "search" && path != "list" {
|
||||
return triagePageToken{}, mailValidationParamError("--page-token", "invalid --page-token: must start with 'search:' or 'list:' prefix, got %q", path)
|
||||
return triagePageToken{}, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix, got %q", path)
|
||||
}
|
||||
if raw == "" {
|
||||
return triagePageToken{}, mailValidationParamError("--page-token", "invalid --page-token: token value is empty after '%s:' prefix", path)
|
||||
return triagePageToken{}, fmt.Errorf("invalid --page-token: token value is empty after '%s:' prefix", path)
|
||||
}
|
||||
return triagePageToken{Path: path, RawToken: raw}, nil
|
||||
}
|
||||
@@ -1109,18 +1090,24 @@ func doJSONAPI(runtime *common.RuntimeContext, req *larkcore.ApiReq, action stri
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= triageAPIRetries; attempt++ {
|
||||
apiResp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
lastErr = mailDecorateProblemMessage(client.WrapDoAPIError(err), "%s", action)
|
||||
if attempt == triageAPIRetries {
|
||||
return nil, lastErr
|
||||
if err == nil {
|
||||
var result interface{}
|
||||
dec := json.NewDecoder(bytes.NewReader(apiResp.RawBody))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&result); err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: response parse error: %s", action, err)
|
||||
}
|
||||
} else {
|
||||
data, handleErr := runtime.ClassifyAPIResponse(apiResp)
|
||||
data, handleErr := common.HandleApiResult(result, nil, action)
|
||||
if handleErr == nil {
|
||||
return data, nil
|
||||
}
|
||||
lastErr = mailDecorateProblemMessage(handleErr, "%s", action)
|
||||
if !shouldRetryTriageAPIError(lastErr) || attempt == triageAPIRetries {
|
||||
lastErr = handleErr
|
||||
if !shouldRetryTriageAPIError(handleErr) || attempt == triageAPIRetries {
|
||||
return nil, handleErr
|
||||
}
|
||||
} else {
|
||||
lastErr = output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
|
||||
if attempt == triageAPIRetries {
|
||||
return nil, lastErr
|
||||
}
|
||||
}
|
||||
@@ -1130,11 +1117,11 @@ func doJSONAPI(runtime *common.RuntimeContext, req *larkcore.ApiReq, action stri
|
||||
}
|
||||
|
||||
func shouldRetryTriageAPIError(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok || exitErr.Detail == nil {
|
||||
return false
|
||||
}
|
||||
return p.Subtype == errs.SubtypeRateLimit || p.Category == errs.CategoryNetwork
|
||||
return exitErr.Detail.Type == "rate_limit" || exitErr.Code == output.ExitNetwork
|
||||
}
|
||||
|
||||
func toQueryParams(params map[string]interface{}) larkcore.QueryParams {
|
||||
|
||||
@@ -7,13 +7,10 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -122,36 +119,6 @@ func TestBuildSearchParamsSystemLabelAsFolder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageRejectsDangerousQueryWithTypedValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage", "--as", "user", "--query", "bad\x01",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected dangerous --query to return an error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--query" {
|
||||
t.Errorf("param = %q, want --query", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(p.Message, "control character") {
|
||||
t.Errorf("message should mention control character, got: %s", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemLabelViaFolderField(t *testing.T) {
|
||||
// System label passed via folder field should also be converted to search folder value.
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
@@ -739,43 +706,6 @@ func TestFormatAddressFallbackToAddress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRetryTriageAPIError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "rate limit",
|
||||
err: errs.NewAPIError(errs.SubtypeRateLimit, "too many requests"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "network",
|
||||
err: errs.NewNetworkError(errs.SubtypeNetworkTransport, "dial timeout"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "validation",
|
||||
err: errs.NewValidationError(errs.SubtypeInvalidArgument, "bad query"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain",
|
||||
err: assertErr("legacy plain error"),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := shouldRetryTriageAPIError(tc.err); got != tc.want {
|
||||
t.Fatalf("shouldRetryTriageAPIError() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- extractTriageMessageIDs ---
|
||||
|
||||
func TestExtractTriageMessageIDsStringItems(t *testing.T) {
|
||||
@@ -1475,279 +1405,3 @@ func TestParseTriagePageTokenInvalidPrefix(t *testing.T) {
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// --- mailbox_id preservation tests ---
|
||||
|
||||
func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mailbox string
|
||||
format string
|
||||
args []string
|
||||
register func(*httpmock.Registry, string)
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "list json default mailbox",
|
||||
mailbox: "me",
|
||||
format: "json",
|
||||
args: []string{"--filter", `{"folder_id":"INBOX"}`},
|
||||
register: func(reg *httpmock.Registry, mailbox string) {
|
||||
registerMailTriageListStub(reg, mailbox, []string{"msg_001", "msg_002"}, false, "")
|
||||
registerMailTriageBatchStub(reg, mailbox, []map[string]interface{}{
|
||||
mailTriageBatchMessage("msg_001", "Subject 1"),
|
||||
mailTriageBatchMessage("msg_002", "Subject 2"),
|
||||
})
|
||||
},
|
||||
wantCount: 2,
|
||||
},
|
||||
{
|
||||
name: "list data public mailbox",
|
||||
mailbox: "shared@company.com",
|
||||
format: "data",
|
||||
args: []string{"--filter", `{"folder_id":"INBOX"}`},
|
||||
register: func(reg *httpmock.Registry, mailbox string) {
|
||||
registerMailTriageListStub(reg, mailbox, []string{"msg_pub_001"}, false, "")
|
||||
registerMailTriageBatchStub(reg, mailbox, []map[string]interface{}{
|
||||
mailTriageBatchMessage("msg_pub_001", "Shared mailbox message"),
|
||||
})
|
||||
},
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "search json public mailbox",
|
||||
mailbox: "shared@corp.com",
|
||||
format: "json",
|
||||
args: []string{"--query", "shared keyword"},
|
||||
register: func(reg *httpmock.Registry, mailbox string) {
|
||||
registerMailTriageSearchStub(reg, mailbox, []interface{}{
|
||||
mailTriageSearchItem("search_pub_001", "Shared search"),
|
||||
}, false, "")
|
||||
},
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "empty list json keeps top-level mailbox",
|
||||
mailbox: "me",
|
||||
format: "json",
|
||||
args: []string{"--filter", `{"folder_id":"INBOX"}`},
|
||||
register: func(reg *httpmock.Registry, mailbox string) {
|
||||
registerMailTriageListStub(reg, mailbox, nil, false, "")
|
||||
},
|
||||
wantCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
|
||||
tt.register(reg, tt.mailbox)
|
||||
|
||||
args := []string{"+triage", "--format", tt.format}
|
||||
if tt.mailbox != "me" {
|
||||
args = append(args, "--mailbox", tt.mailbox)
|
||||
}
|
||||
args = append(args, tt.args...)
|
||||
|
||||
if err := runMountedMailShortcut(t, MailTriage, args, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeMailTriageJSONOutput(t, stdout)
|
||||
if data["mailbox_id"] != tt.mailbox {
|
||||
t.Fatalf("top-level mailbox_id mismatch: got %v, want %q", data["mailbox_id"], tt.mailbox)
|
||||
}
|
||||
messages := mailTriageMessagesFromOutput(t, data)
|
||||
if len(messages) != tt.wantCount {
|
||||
t.Fatalf("message count mismatch: got %d, want %d", len(messages), tt.wantCount)
|
||||
}
|
||||
for i, msg := range messages {
|
||||
if msg["mailbox_id"] != tt.mailbox {
|
||||
t.Fatalf("message[%d] mailbox_id mismatch: got %v, want %q", i, msg["mailbox_id"], tt.mailbox)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
|
||||
registerMailTriageListStub(reg, "me", []string{"msg_ok", "msg_missing"}, false, "")
|
||||
registerMailTriageBatchStub(reg, "me", []map[string]interface{}{
|
||||
mailTriageBatchMessage("msg_ok", "Present"),
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage",
|
||||
"--format", "json",
|
||||
"--filter", `{"folder_id":"INBOX"}`,
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
messages := mailTriageMessagesFromOutput(t, decodeMailTriageJSONOutput(t, stdout))
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(messages))
|
||||
}
|
||||
for i, msg := range messages {
|
||||
if msg["mailbox_id"] != "me" {
|
||||
t.Fatalf("message[%d] mailbox_id mismatch: got %v, want me", i, msg["mailbox_id"])
|
||||
}
|
||||
}
|
||||
if messages[1]["message_id"] != "msg_missing" || messages[1]["error"] == nil {
|
||||
t.Fatalf("missing metadata placeholder mismatch: %#v", messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mailbox string
|
||||
hasMore bool
|
||||
wantMailboxColumn bool
|
||||
wantMailboxHint bool
|
||||
}{
|
||||
{name: "default mailbox", mailbox: "me"},
|
||||
{name: "public mailbox", mailbox: "shared@company.com", hasMore: true, wantMailboxColumn: true, wantMailboxHint: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
|
||||
registerMailTriageListStub(reg, tt.mailbox, []string{"msg_001"}, tt.hasMore, "next_page_token")
|
||||
registerMailTriageBatchStub(reg, tt.mailbox, []map[string]interface{}{
|
||||
mailTriageBatchMessage("msg_001", "Table message"),
|
||||
})
|
||||
|
||||
args := []string{"+triage", "--max", "1", "--filter", `{"folder_id":"INBOX"}`}
|
||||
if tt.mailbox != "me" {
|
||||
args = append(args, "--mailbox", tt.mailbox)
|
||||
}
|
||||
if err := runMountedMailShortcut(t, MailTriage, args, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if got := strings.Contains(out, "mailbox_id"); got != tt.wantMailboxColumn {
|
||||
t.Fatalf("mailbox_id column presence mismatch: got %v, want %v\nstdout:\n%s", got, tt.wantMailboxColumn, out)
|
||||
}
|
||||
if tt.wantMailboxColumn && !strings.Contains(out, tt.mailbox) {
|
||||
t.Fatalf("table output should contain mailbox %q, stdout:\n%s", tt.mailbox, out)
|
||||
}
|
||||
|
||||
errOut := stderr.String()
|
||||
quotedMailbox := shellQuote(tt.mailbox)
|
||||
if got := strings.Contains(errOut, "--mailbox "+quotedMailbox); got != tt.wantMailboxHint {
|
||||
t.Fatalf("mailbox hint presence mismatch: got %v, want %v\nstderr:\n%s", got, tt.wantMailboxHint, errOut)
|
||||
}
|
||||
if !strings.Contains(errOut, "mail +message") {
|
||||
t.Fatalf("stderr should contain mail +message tip, got:\n%s", errOut)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }) map[string]interface{} {
|
||||
t.Helper()
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func mailTriageMessagesFromOutput(t *testing.T, data map[string]interface{}) []map[string]interface{} {
|
||||
t.Helper()
|
||||
rawMessages, ok := data["messages"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("messages type mismatch: %T", data["messages"])
|
||||
}
|
||||
messages := make([]map[string]interface{}, 0, len(rawMessages))
|
||||
for i, item := range rawMessages {
|
||||
msg, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("messages[%d] type mismatch: %T", i, item)
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func registerMailTriageListStub(reg *httpmock.Registry, mailbox string, items []string, hasMore bool, pageToken string) {
|
||||
data := map[string]interface{}{
|
||||
"items": items,
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if pageToken != "" {
|
||||
data["page_token"] = pageToken
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: mailboxPath(mailbox, "messages") + "?",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func registerMailTriageBatchStub(reg *httpmock.Registry, mailbox string, messages []map[string]interface{}) {
|
||||
rawMessages := make([]interface{}, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
rawMessages = append(rawMessages, msg)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: mailboxPath(mailbox, "messages", "batch_get"),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"messages": rawMessages,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string) {
|
||||
data := map[string]interface{}{
|
||||
"items": items,
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if pageToken != "" {
|
||||
data["page_token"] = pageToken
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: mailboxPath(mailbox, "search"),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func mailTriageBatchMessage(messageID, subject string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"message_id": messageID,
|
||||
"subject": subject,
|
||||
"head_from": map[string]interface{}{"name": "Alice", "mail_address": "alice@example.com"},
|
||||
"folder_id": "INBOX",
|
||||
}
|
||||
}
|
||||
|
||||
func mailTriageSearchItem(messageID, subject string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"meta_data": map[string]interface{}{
|
||||
"message_biz_id": messageID,
|
||||
"title": subject,
|
||||
"from": map[string]interface{}{"name": "Alice", "mail_address": "alice@example.com"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -22,8 +23,6 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -188,7 +187,7 @@ var MailWatch = common.Shortcut{
|
||||
switch outFormat {
|
||||
case "json", "data", "":
|
||||
default:
|
||||
return mailValidationParamError("--format", "invalid --format %q: must be json or data", outFormat)
|
||||
return output.ErrValidation("invalid --format %q: must be json or data", outFormat)
|
||||
}
|
||||
msgFormat := runtime.Str("msg-format")
|
||||
outputDir := runtime.Str("output-dir")
|
||||
@@ -197,18 +196,18 @@ var MailWatch = common.Shortcut{
|
||||
// literal relative path (creating a directory named "~"), which is
|
||||
// confusing. This also covers ~user/path forms.
|
||||
if strings.HasPrefix(outputDir, "~") {
|
||||
return mailValidationParamError("--output-dir", "--output-dir does not support ~ expansion; use a relative path like ./output instead")
|
||||
return output.ErrValidation("--output-dir does not support ~ expansion; use a relative path like ./output instead")
|
||||
}
|
||||
// Enforce CWD containment: reject absolute paths, path traversal,
|
||||
// and symlink escapes. SafeOutputPath returns a resolved absolute path
|
||||
// under CWD, preventing writes to arbitrary system directories.
|
||||
safePath, err := validate.SafeOutputPath(outputDir)
|
||||
if err != nil {
|
||||
return mailValidationParamError("--output-dir", "invalid --output-dir %q: %v", outputDir, err).WithCause(err)
|
||||
return err
|
||||
}
|
||||
outputDir = safePath
|
||||
if err := vfs.MkdirAll(outputDir, 0700); err != nil {
|
||||
return mailFileIOError("cannot create output directory %q: %v", err, outputDir, err)
|
||||
return fmt.Errorf("cannot create output directory %q: %w", outputDir, err)
|
||||
}
|
||||
}
|
||||
labelIDsInput := runtime.Str("label-ids")
|
||||
@@ -247,7 +246,7 @@ var MailWatch = common.Shortcut{
|
||||
|
||||
// Step 1: subscribe mailbox events (required before WebSocket pushes mail events)
|
||||
info(fmt.Sprintf("Subscribing mailbox events for: %s", mailbox))
|
||||
_, err = runtime.CallAPITyped("POST", mailboxPath(mailbox, "event", "subscribe"), nil, map[string]interface{}{"event_type": 1})
|
||||
_, err = runtime.CallAPI("POST", mailboxPath(mailbox, "event", "subscribe"), nil, map[string]interface{}{"event_type": 1})
|
||||
if err != nil {
|
||||
return wrapWatchSubscribeError(err)
|
||||
}
|
||||
@@ -257,7 +256,7 @@ var MailWatch = common.Shortcut{
|
||||
var unsubErr error
|
||||
unsubscribe := func() error {
|
||||
unsubOnce.Do(func() {
|
||||
_, unsubErr = runtime.CallAPITyped("POST", mailboxPath(mailbox, "event", "unsubscribe"), nil, map[string]interface{}{"event_type": 1})
|
||||
_, unsubErr = runtime.CallAPI("POST", mailboxPath(mailbox, "event", "unsubscribe"), nil, map[string]interface{}{"event_type": 1})
|
||||
})
|
||||
return unsubErr
|
||||
}
|
||||
@@ -486,7 +485,7 @@ var MailWatch = common.Shortcut{
|
||||
if watchCtx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "WebSocket connection failed: %v", err).WithCause(err)
|
||||
return output.ErrNetwork("WebSocket connection failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -509,7 +508,7 @@ func parseJSONArrayFlag(input, flagName string) ([]string, error) {
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(trimmed), &values); err != nil {
|
||||
return nil, mailValidationParamError("--"+flagName, "invalid --%s: expected JSON array of strings, e.g. [\"INBOX\",\"SENT\"]", flagName).WithCause(err)
|
||||
return nil, output.ErrValidation("invalid --%s: expected JSON array of strings, e.g. [\"INBOX\",\"SENT\"]", flagName)
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
@@ -713,13 +712,19 @@ func fetchMessageForWatch(runtime *common.RuntimeContext, mailbox, messageID, fo
|
||||
ApiPath: fmt.Sprintf("/open-apis/mail/v1/user_mailboxes/%s/messages/%s", validate.EncodePathSegment(mailbox), validate.EncodePathSegment(messageID)),
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if code, _ := result["code"].(float64); code != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return nil, fmt.Errorf("[%.0f] %s", code, msg)
|
||||
}
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
msg, _ := data["message"].(map[string]interface{})
|
||||
if msg == nil {
|
||||
return data, nil
|
||||
@@ -743,40 +748,29 @@ func wrapWatchSubscribeError(err error) error {
|
||||
return nil
|
||||
}
|
||||
hint := "ensure the app has scope mail:event and the event mail.user_mailbox.event.message_received_v1 is enabled"
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
p.Message = "subscribe mailbox events failed: " + p.Message
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
p.Hint = p.Hint + "; " + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
if exitErr, ok := err.(*output.ExitError); ok && exitErr.Detail != nil {
|
||||
msg := "subscribe mailbox events failed: " + exitErr.Detail.Message
|
||||
if exitErr.Detail.Hint != "" {
|
||||
hint = exitErr.Detail.Hint + "; " + hint
|
||||
}
|
||||
return err
|
||||
return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, msg, hint)
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "subscribe mailbox events failed: %v", err).WithHint("%s", hint).WithCause(err)
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error", fmt.Sprintf("subscribe mailbox events failed: %v", err), hint)
|
||||
}
|
||||
|
||||
// enhanceProfileError wraps a profile API error with actionable hints.
|
||||
// Permission errors get a scope-specific hint; other errors (network, 5xx)
|
||||
// are reported as-is so diagnostics aren't misleading.
|
||||
func enhanceProfileError(err error) error {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
lower := strings.ToLower(p.Message)
|
||||
if p.Category == errs.CategoryAuthorization {
|
||||
p.Message = "unable to resolve mailbox address: " + p.Message
|
||||
p.Hint = "run `lark-cli auth login --scope \"mail:user_mailbox:readonly\"` to grant mailbox profile access"
|
||||
return err
|
||||
}
|
||||
if strings.Contains(lower, "permission") || strings.Contains(lower, "scope") {
|
||||
permErr := errs.NewPermissionError(errs.SubtypeMissingScope, "unable to resolve mailbox address: %s", p.Message).
|
||||
WithHint("run `lark-cli auth login --scope \"mail:user_mailbox:readonly\"` to grant mailbox profile access").
|
||||
WithCause(err)
|
||||
if p.Code != 0 {
|
||||
permErr = permErr.WithCode(p.Code)
|
||||
}
|
||||
if p.LogID != "" {
|
||||
permErr = permErr.WithLogID(p.LogID)
|
||||
}
|
||||
return permErr
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
errType := exitErr.Detail.Type
|
||||
lower := strings.ToLower(exitErr.Detail.Message)
|
||||
if errType == "permission" || errType == "missing_scope" ||
|
||||
strings.Contains(lower, "permission") || strings.Contains(lower, "scope") {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
"unable to resolve mailbox address: "+exitErr.Detail.Message,
|
||||
"run `lark-cli auth login --scope \"mail:user_mailbox:readonly\"` to grant mailbox profile access")
|
||||
}
|
||||
}
|
||||
// Preserve original error (and its exit code) for non-permission failures.
|
||||
|
||||
@@ -8,19 +8,16 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -224,72 +221,6 @@ func TestMailWatchDryRunEventFormatWithLabelFilterFetchesMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailWatchOutputDirRejectsUnsafePathTyped(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
|
||||
err := runMountedMailShortcut(t, MailWatch, []string{
|
||||
"+watch",
|
||||
"--output-dir", "../escape",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe output-dir error")
|
||||
}
|
||||
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--output-dir" {
|
||||
t.Fatalf("param = %q, want --output-dir", validationErr.Param)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailWatchOutputDirMkdirFailureTyped(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
mkdirErr := errors.New("mkdir denied")
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
oldFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = failingMkdirFS{OsFs: vfs.OsFs{}, err: mkdirErr}
|
||||
t.Cleanup(func() { vfs.DefaultFS = oldFS })
|
||||
|
||||
err := runMountedMailShortcut(t, MailWatch, []string{
|
||||
"+watch",
|
||||
"--output-dir", "watch-output",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected mkdir error")
|
||||
}
|
||||
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("expected internal error, got %T: %v", err, err)
|
||||
}
|
||||
if !errors.Is(err, mkdirErr) {
|
||||
t.Fatalf("cause not preserved: %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeFileIO {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeFileIO)
|
||||
}
|
||||
if strings.Contains(p.Message, "%!(") {
|
||||
t.Fatalf("message contains fmt extra marker: %q", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Message, "cannot create output directory") || !strings.Contains(p.Message, "mkdir denied") {
|
||||
t.Fatalf("message missing context: %q", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchFetchFailureValue(t *testing.T) {
|
||||
value := watchFetchFailureValue("msg_123", "metadata", assertErr("boom"), map[string]interface{}{
|
||||
"mail_address": "alice@example.com",
|
||||
@@ -592,93 +523,24 @@ func TestWrapWatchSubscribeErrorPlain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWatchSubscribeErrorTypedProblem(t *testing.T) {
|
||||
apiErr := errs.NewAPIError(errs.SubtypePermissionDenied, "permission denied").
|
||||
WithHint("check app permissions")
|
||||
err := wrapWatchSubscribeError(apiErr)
|
||||
func TestWrapWatchSubscribeErrorExitError(t *testing.T) {
|
||||
exitErr := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Message: "permission denied",
|
||||
Hint: "check app permissions",
|
||||
},
|
||||
}
|
||||
err := wrapWatchSubscribeError(exitErr)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
if !strings.Contains(err.Error(), "subscribe mailbox events failed") {
|
||||
t.Fatalf("unexpected message: %v", err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "subscribe mailbox events failed") {
|
||||
t.Fatalf("unexpected message: %v", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Message, "permission denied") {
|
||||
t.Fatalf("original message missing: %v", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "check app permissions") {
|
||||
t.Fatalf("original hint missing: %v", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWatchSubscribeErrorTypedProblemAddsMissingHint(t *testing.T) {
|
||||
apiErr := errs.NewAPIError(errs.SubtypePermissionDenied, "permission denied")
|
||||
|
||||
err := wrapWatchSubscribeError(apiErr)
|
||||
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "mail:event") {
|
||||
t.Fatalf("scope hint missing: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnhanceProfileErrorAuthorization(t *testing.T) {
|
||||
original := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope")
|
||||
|
||||
err := enhanceProfileError(original)
|
||||
|
||||
if err != original {
|
||||
t.Fatalf("authorization error should be updated in place")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "unable to resolve mailbox address") {
|
||||
t.Fatalf("message missing mailbox context: %q", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "mail:user_mailbox:readonly") {
|
||||
t.Fatalf("profile scope hint missing: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnhanceProfileErrorPermissionMessagePromotesToMissingScope(t *testing.T) {
|
||||
original := errs.NewAPIError(errs.SubtypeUnknown, "scope denied").
|
||||
WithCode(99991679).
|
||||
WithLogID("logid-profile")
|
||||
|
||||
err := enhanceProfileError(original)
|
||||
|
||||
var permissionErr *errs.PermissionError
|
||||
if !errors.As(err, &permissionErr) {
|
||||
t.Fatalf("expected permission error, got %T", err)
|
||||
}
|
||||
if !errors.Is(err, original) {
|
||||
t.Fatalf("original error not preserved as cause: %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeMissingScope {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeMissingScope)
|
||||
}
|
||||
if p.Code != 99991679 || p.LogID != "logid-profile" {
|
||||
t.Fatalf("code/logid not preserved: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnhanceProfileErrorPreservesNonPermissionError(t *testing.T) {
|
||||
original := errs.NewNetworkError(errs.SubtypeNetworkTransport, "dial timeout")
|
||||
|
||||
if got := enhanceProfileError(original); got != original {
|
||||
t.Fatalf("non-permission errors should pass through, got %T", got)
|
||||
if !strings.Contains(err.Error(), "permission denied") {
|
||||
t.Fatalf("original message missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,15 +687,6 @@ type testErr struct{ msg string }
|
||||
|
||||
func (e *testErr) Error() string { return e.msg }
|
||||
|
||||
type failingMkdirFS struct {
|
||||
vfs.OsFs
|
||||
err error
|
||||
}
|
||||
|
||||
func (f failingMkdirFS) MkdirAll(string, fs.FileMode) error {
|
||||
return f.err
|
||||
}
|
||||
|
||||
type watchDryRunPayload struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
|
||||
@@ -5,9 +5,9 @@ package signature
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -27,19 +27,19 @@ func ListAll(runtime *common.RuntimeContext, mailboxID string) (*GetSignaturesRe
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("GET", signaturesPath(mailboxID), nil, nil)
|
||||
data, err := runtime.CallAPI("GET", signaturesPath(mailboxID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("get signatures: %w", err)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "get signatures: marshal response: %v", err).WithCause(err)
|
||||
return nil, fmt.Errorf("get signatures: marshal response: %w", err)
|
||||
}
|
||||
|
||||
var resp GetSignaturesResponse
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "get signatures: unmarshal response: %v", err).WithCause(err)
|
||||
return nil, fmt.Errorf("get signatures: unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
processCache[mailboxID] = &resp
|
||||
@@ -66,5 +66,5 @@ func Get(runtime *common.RuntimeContext, mailboxID, signatureID string) (*Signat
|
||||
return &resp.Signatures[i], nil
|
||||
}
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "signature not found: %s", signatureID)
|
||||
return nil, fmt.Errorf("signature not found: %s", signatureID)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
@@ -32,6 +33,8 @@ type signatureResult struct {
|
||||
Images []draftpkg.SignatureImage
|
||||
}
|
||||
|
||||
// resolveSignature fetches, interpolates, and downloads images for a signature.
|
||||
// Returns nil if signatureID is empty.
|
||||
// resolveSignature fetches, interpolates, and downloads images for a signature.
|
||||
// fromEmail is the --from address (may be an alias); used to match the correct
|
||||
// sender identity for template interpolation. Pass "" to use the primary address.
|
||||
@@ -59,7 +62,7 @@ func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailb
|
||||
}
|
||||
data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
|
||||
if err != nil {
|
||||
return nil, mailDecorateProblemMessage(err, "failed to download signature image %s", img.ImageName)
|
||||
return nil, fmt.Errorf("failed to download signature image %s: %w", img.ImageName, err)
|
||||
}
|
||||
images = append(images, draftpkg.SignatureImage{
|
||||
CID: img.CID,
|
||||
@@ -107,12 +110,13 @@ func addSignatureImagesToBuilder(bld emlbuilder.Builder, sig *signatureResult) e
|
||||
return bld
|
||||
}
|
||||
|
||||
// resolveSenderInfo fetches senderName and senderEmail via the send_as API.
|
||||
// resolveSenderInfo fetches send_as addresses and returns the name/email
|
||||
// for signature interpolation. If fromEmail is non-empty, it matches
|
||||
// that address in the sendable list (for alias/send_as scenarios);
|
||||
// otherwise falls back to the first (primary) address.
|
||||
func resolveSenderInfo(runtime *common.RuntimeContext, mailboxID, fromEmail string) (name, email string) {
|
||||
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "settings", "send_as"), nil, nil)
|
||||
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "settings", "send_as"), nil, nil)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
@@ -151,54 +155,45 @@ func resolveSenderInfo(runtime *common.RuntimeContext, mailboxID, fromEmail stri
|
||||
func downloadSignatureImage(runtime *common.RuntimeContext, downloadURL, filename string) ([]byte, string, error) {
|
||||
u, err := url.Parse(downloadURL)
|
||||
if err != nil {
|
||||
return nil, "", mailInvalidResponseError("signature image download: invalid URL: %v", err).WithCause(err)
|
||||
return nil, "", fmt.Errorf("signature image download: invalid URL: %w", err)
|
||||
}
|
||||
if u.Scheme != "https" {
|
||||
return nil, "", mailInvalidResponseError("signature image download: URL must use https (got %q)", u.Scheme)
|
||||
return nil, "", fmt.Errorf("signature image download: URL must use https (got %q)", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, "", mailInvalidResponseError("signature image download: URL has no host")
|
||||
return nil, "", fmt.Errorf("signature image download: URL has no host")
|
||||
}
|
||||
|
||||
httpClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "signature image download: %v", err).WithCause(err)
|
||||
return nil, "", fmt.Errorf("signature image download: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(runtime.Ctx(), 30*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "signature image download: %v", err).WithCause(err)
|
||||
return nil, "", fmt.Errorf("signature image download: %w", err)
|
||||
}
|
||||
// Do NOT send Authorization: the download URL is pre-signed.
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "signature image download: %v", err).WithCause(err)
|
||||
return nil, "", fmt.Errorf("signature image download: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, "", errs.NewNetworkError(errs.SubtypeNetworkServer, "signature image download: HTTP %d: %s", resp.StatusCode, string(body)).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return nil, "", errs.NewAPIError(subtype, "signature image download: HTTP %d: %s", resp.StatusCode, string(body)).WithCode(resp.StatusCode)
|
||||
return nil, "", fmt.Errorf("signature image download: HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
|
||||
if err != nil {
|
||||
return nil, "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "signature image download: read body: %v", err).WithCause(err)
|
||||
return nil, "", fmt.Errorf("signature image download: read body: %w", err)
|
||||
}
|
||||
if len(data) > maxSize {
|
||||
return nil, "", mailFailedPreconditionError("signature image download: file exceeds 10MB limit")
|
||||
return nil, "", fmt.Errorf("signature image download: file exceeds 10MB limit")
|
||||
}
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
@@ -247,11 +242,7 @@ func signatureCIDs(sig *signatureResult) []string {
|
||||
// validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set.
|
||||
func validateSignatureWithPlainText(plainText bool, signatureID string) error {
|
||||
if plainText && signatureID != "" {
|
||||
return mailValidationError("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode").
|
||||
WithParams(
|
||||
mailInvalidParam("--plain-text", "mutually exclusive with --signature-id"),
|
||||
mailInvalidParam("--signature-id", "requires HTML mode"),
|
||||
)
|
||||
return output.ErrValidation("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestDownloadSignatureImageRejectsInvalidURLs(t *testing.T) {
|
||||
rt := newDownloadRuntime(t, &http.Client{})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{name: "invalid", url: "https://[::1"},
|
||||
{name: "http", url: "http://example.com/sig.png"},
|
||||
{name: "no host", url: "https:///sig.png"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := downloadSignatureImage(rt, tc.url, "sig.png")
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("expected internal error, got %T (%v)", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSignatureImageHTTPErrorClassification(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
statusCode int
|
||||
wantType any
|
||||
wantSub errs.Subtype
|
||||
retryable bool
|
||||
}{
|
||||
{
|
||||
name: "server",
|
||||
statusCode: http.StatusInternalServerError,
|
||||
wantType: (*errs.NetworkError)(nil),
|
||||
wantSub: errs.SubtypeNetworkServer,
|
||||
retryable: true,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
statusCode: http.StatusNotFound,
|
||||
wantType: (*errs.APIError)(nil),
|
||||
wantSub: errs.SubtypeNotFound,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "download failed", tc.statusCode)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
rt := newDownloadRuntime(t, srv.Client())
|
||||
|
||||
_, _, err := downloadSignatureImage(rt, srv.URL+"/sig.png", "sig.png")
|
||||
switch tc.wantType.(type) {
|
||||
case *errs.NetworkError:
|
||||
var networkErr *errs.NetworkError
|
||||
if !errors.As(err, &networkErr) {
|
||||
t.Fatalf("expected network error, got %T (%v)", err, err)
|
||||
}
|
||||
case *errs.APIError:
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected API error, got %T (%v)", err, err)
|
||||
}
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Code != tc.statusCode {
|
||||
t.Fatalf("code = %d, want %d", p.Code, tc.statusCode)
|
||||
}
|
||||
if p.Subtype != tc.wantSub {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, tc.wantSub)
|
||||
}
|
||||
if p.Retryable != tc.retryable {
|
||||
t.Fatalf("retryable = %v, want %v", p.Retryable, tc.retryable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSignatureImageReadAndSizeErrors(t *testing.T) {
|
||||
readErr := errors.New("socket closed")
|
||||
rt := newDownloadRuntime(t, &http.Client{
|
||||
Transport: signatureRoundTripper(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: signatureErrorBody{err: readErr},
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
_, _, err := downloadSignatureImage(rt, "https://example.com/sig.png", "sig.png")
|
||||
var networkErr *errs.NetworkError
|
||||
if !errors.As(err, &networkErr) {
|
||||
t.Fatalf("expected network error, got %T (%v)", err, err)
|
||||
}
|
||||
if !errors.Is(err, readErr) {
|
||||
t.Fatalf("read cause not preserved: %v", err)
|
||||
}
|
||||
|
||||
rt = newDownloadRuntime(t, &http.Client{
|
||||
Transport: signatureRoundTripper(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: &bodyFileTestFile{remaining: 10*1024*1024 + 1},
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
_, _, err = downloadSignatureImage(rt, "https://example.com/huge.png", "huge.png")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T (%v)", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSignatureImageSuccessUsesFilenameContentType(t *testing.T) {
|
||||
rt := newDownloadRuntime(t, &http.Client{
|
||||
Transport: signatureRoundTripper(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("gif-data")),
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
data, contentType, err := downloadSignatureImage(rt, "https://example.com/sig.gif", "sig.gif")
|
||||
if err != nil {
|
||||
t.Fatalf("downloadSignatureImage failed: %v", err)
|
||||
}
|
||||
if string(data) != "gif-data" {
|
||||
t.Fatalf("data = %q", string(data))
|
||||
}
|
||||
if contentType != "image/gif" {
|
||||
t.Fatalf("content type = %q, want image/gif", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSignatureWithPlainTextTypedError(t *testing.T) {
|
||||
err := validateSignatureWithPlainText(true, "sig_123")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T (%v)", err, err)
|
||||
}
|
||||
if len(validationErr.Params) != 2 {
|
||||
t.Fatalf("params = %#v, want two conflicting params", validationErr.Params)
|
||||
}
|
||||
if validationErr.Params[0].Name != "--plain-text" || validationErr.Params[1].Name != "--signature-id" {
|
||||
t.Fatalf("unexpected params: %#v", validationErr.Params)
|
||||
}
|
||||
}
|
||||
|
||||
type signatureRoundTripper func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (rt signatureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return rt(req)
|
||||
}
|
||||
|
||||
type signatureErrorBody struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (b signatureErrorBody) Read([]byte) (int, error) {
|
||||
return 0, b.err
|
||||
}
|
||||
|
||||
func (b signatureErrorBody) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
@@ -222,7 +222,7 @@ func validateTemplateID(tid string) error {
|
||||
return nil
|
||||
}
|
||||
if _, err := strconv.ParseInt(tid, 10, 64); err != nil {
|
||||
return mailValidationParamError("--template-id", "--template-id must be a decimal integer string")
|
||||
return output.ErrValidation("--template-id must be a decimal integer string")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -264,7 +264,7 @@ func joinTemplateAddresses(addrs []templateMailAddr) string {
|
||||
func generateTemplateCID() (string, error) {
|
||||
id, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeSDKError, "failed to generate CID: %v", err).WithCause(err)
|
||||
return "", fmt.Errorf("failed to generate CID: %w", err)
|
||||
}
|
||||
return id.String(), nil
|
||||
}
|
||||
@@ -276,23 +276,23 @@ func generateTemplateCID() (string, error) {
|
||||
func uploadToDriveForTemplate(ctx context.Context, runtime *common.RuntimeContext, path string) (fileKey string, size int64, err error) {
|
||||
info, err := runtime.FileIO().Stat(path)
|
||||
if err != nil {
|
||||
return "", 0, mailInputStatError(err)
|
||||
return "", 0, fmt.Errorf("failed to stat %s: %w", path, err)
|
||||
}
|
||||
size = info.Size()
|
||||
if size > MaxLargeAttachmentSize {
|
||||
return "", size, mailFailedPreconditionError("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
|
||||
return "", size, fmt.Errorf("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
|
||||
filepath.Base(path), float64(size)/1024/1024/1024, float64(MaxLargeAttachmentSize)/1024/1024/1024)
|
||||
}
|
||||
name := filepath.Base(path)
|
||||
if err := filecheck.CheckBlockedExtension(name); err != nil {
|
||||
return "", size, mailValidationError("%v", err).WithCause(err)
|
||||
return "", size, err
|
||||
}
|
||||
userOpenId := runtime.UserOpenId()
|
||||
if userOpenId == "" {
|
||||
return "", size, mailFailedPreconditionError("template attachment upload requires user identity (--as user)")
|
||||
return "", size, fmt.Errorf("template attachment upload requires user identity (--as user)")
|
||||
}
|
||||
if size <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
fileKey, err = common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
fileKey, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: path,
|
||||
FileName: name,
|
||||
FileSize: size,
|
||||
@@ -300,7 +300,7 @@ func uploadToDriveForTemplate(ctx context.Context, runtime *common.RuntimeContex
|
||||
ParentNode: &userOpenId,
|
||||
})
|
||||
} else {
|
||||
fileKey, err = common.UploadDriveMediaMultipartTyped(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
fileKey, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: path,
|
||||
FileName: name,
|
||||
FileSize: size,
|
||||
@@ -309,7 +309,7 @@ func uploadToDriveForTemplate(ctx context.Context, runtime *common.RuntimeContex
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return "", size, mailDecorateProblemMessage(err, "upload %s to Drive failed", name)
|
||||
return "", size, fmt.Errorf("upload %s to Drive failed: %w", name, err)
|
||||
}
|
||||
return fileKey, size, nil
|
||||
}
|
||||
@@ -404,7 +404,7 @@ func (b *templateAttachmentBuilder) append(fileKey, filename, cid string, isInli
|
||||
// self-healing via the LARGE switch inside append().
|
||||
func (b *templateAttachmentBuilder) finalize() error {
|
||||
if b.rawBodyInlineSmall > maxTemplateBodyInlineSmallBytes {
|
||||
return mailFailedPreconditionError("template body + inline images exceed %d MB (got %.1f MB); "+
|
||||
return fmt.Errorf("template body + inline images exceed %d MB (got %.1f MB); "+
|
||||
"reduce inline image size or count — inline images cannot be promoted to LARGE",
|
||||
maxTemplateBodyInlineSmallBytes/(1024*1024),
|
||||
float64(b.rawBodyInlineSmall)/1024/1024)
|
||||
@@ -511,9 +511,9 @@ func replaceImgSrcOnce(html, rawSrc, newSrc string) string {
|
||||
// fetchTemplate GETs a single template (full fields) for --template-id
|
||||
// composition and update patch workflows.
|
||||
func fetchTemplate(runtime *common.RuntimeContext, mailboxID, templateID string) (*templatePayload, error) {
|
||||
data, err := runtime.CallAPITyped("GET", templateMailboxPath(mailboxID, templateID), nil, nil)
|
||||
data, err := runtime.CallAPI("GET", templateMailboxPath(mailboxID, templateID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, mailDecorateProblemMessage(err, "fetch template %s failed", templateID)
|
||||
return nil, fmt.Errorf("fetch template %s failed: %w", templateID, err)
|
||||
}
|
||||
return extractTemplatePayload(data)
|
||||
}
|
||||
@@ -526,29 +526,29 @@ func extractTemplatePayload(data map[string]interface{}) (*templatePayload, erro
|
||||
raw = t
|
||||
}
|
||||
if raw == nil {
|
||||
return nil, mailInvalidResponseError("API response missing template body")
|
||||
return nil, fmt.Errorf("API response missing template body")
|
||||
}
|
||||
buf, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "re-encode template payload failed: %v", err).WithCause(err)
|
||||
return nil, fmt.Errorf("re-encode template payload failed: %w", err)
|
||||
}
|
||||
var out templatePayload
|
||||
if err := json.Unmarshal(buf, &out); err != nil {
|
||||
return nil, mailInvalidResponseError("decode template payload failed: %v", err).WithCause(err)
|
||||
return nil, fmt.Errorf("decode template payload failed: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// createTemplate POSTs a new template.
|
||||
func createTemplate(runtime *common.RuntimeContext, mailboxID string, tpl *templatePayload) (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped("POST", templateMailboxPath(mailboxID), nil, map[string]interface{}{
|
||||
return runtime.CallAPI("POST", templateMailboxPath(mailboxID), nil, map[string]interface{}{
|
||||
"template": tpl,
|
||||
})
|
||||
}
|
||||
|
||||
// updateTemplate PUTs a full-replace update.
|
||||
func updateTemplate(runtime *common.RuntimeContext, mailboxID, templateID string, tpl *templatePayload) (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped("PUT", templateMailboxPath(mailboxID, templateID), nil, map[string]interface{}{
|
||||
return runtime.CallAPI("PUT", templateMailboxPath(mailboxID, templateID), nil, map[string]interface{}{
|
||||
"template": tpl,
|
||||
})
|
||||
}
|
||||
@@ -889,9 +889,9 @@ func fetchTemplateAttachmentURLs(
|
||||
}
|
||||
apiURL := templateMailboxPath(mailboxID, templateID) + "/attachments/download_url?" + strings.Join(parts, "&")
|
||||
|
||||
data, err := runtime.CallAPITyped("GET", apiURL, nil, nil)
|
||||
data, err := runtime.CallAPI("GET", apiURL, nil, nil)
|
||||
if err != nil {
|
||||
return nil, warnings, mailDecorateProblemMessage(err, "template attachments/download_url (template_id=%s)", templateID)
|
||||
return nil, warnings, fmt.Errorf("template attachments/download_url (template_id=%s): %w", templateID, err)
|
||||
}
|
||||
if urls, ok := data["download_urls"].([]interface{}); ok {
|
||||
for _, item := range urls {
|
||||
@@ -976,11 +976,11 @@ func embedTemplateInlineAttachments(
|
||||
for _, ref := range wanted {
|
||||
dlURL, ok := urlMap[ref.FileKey]
|
||||
if !ok || dlURL == "" {
|
||||
return bld, nil, mailInvalidResponseError("template inline image %q (cid=%s): download URL not returned by server", ref.Filename, ref.CID)
|
||||
return bld, nil, fmt.Errorf("template inline image %q (cid=%s): download URL not returned by server", ref.Filename, ref.CID)
|
||||
}
|
||||
bytes, err := downloadAttachmentContent(runtime, dlURL)
|
||||
if err != nil {
|
||||
return bld, nil, mailDecorateProblemMessage(err, "template inline image %q (cid=%s)", ref.Filename, ref.CID)
|
||||
return bld, nil, fmt.Errorf("template inline image %q (cid=%s): %w", ref.Filename, ref.CID, err)
|
||||
}
|
||||
filename := ref.Filename
|
||||
if filename == "" {
|
||||
@@ -988,7 +988,7 @@ func embedTemplateInlineAttachments(
|
||||
}
|
||||
contentType, err := filecheck.CheckInlineImageFormat(filename, bytes)
|
||||
if err != nil {
|
||||
return bld, nil, mailValidationError("template inline image %q (cid=%s): %v", filename, ref.CID, err).WithCause(err)
|
||||
return bld, nil, fmt.Errorf("template inline image %q (cid=%s): %w", filename, ref.CID, err)
|
||||
}
|
||||
bld = bld.AddInline(bytes, contentType, filename, ref.CID)
|
||||
registered = append(registered, ref.CID)
|
||||
@@ -1037,11 +1037,11 @@ func embedTemplateSmallAttachments(
|
||||
}
|
||||
dlURL, ok := urlMap[ref.FileKey]
|
||||
if !ok || dlURL == "" {
|
||||
return bld, 0, mailInvalidResponseError("template attachment %q: download URL not returned by server", ref.Filename)
|
||||
return bld, 0, fmt.Errorf("template attachment %q: download URL not returned by server", ref.Filename)
|
||||
}
|
||||
buf, err := downloadAttachmentContent(runtime, dlURL)
|
||||
if err != nil {
|
||||
return bld, 0, mailDecorateProblemMessage(err, "template attachment %q", ref.Filename)
|
||||
return bld, 0, fmt.Errorf("template attachment %q: %w", ref.Filename, err)
|
||||
}
|
||||
filename := ref.Filename
|
||||
if filename == "" {
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -401,21 +400,17 @@ func TestRegisterShortcutsInstallsMailFlagSuggestHook(t *testing.T) {
|
||||
|
||||
// The FlagErrorFunc lookup walks up to the nearest non-nil hook, so
|
||||
// invoking it on the mail parent (or any of its children) must yield
|
||||
// a typed validation problem for the unknown flag.
|
||||
// a structured *output.ExitError with type "unknown_flag".
|
||||
got := mailCmd.FlagErrorFunc()(mailCmd, errors.New("unknown flag: --bogus"))
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(got, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T (%v)", got, got)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T (%v)", got, got)
|
||||
}
|
||||
if validationErr.Param != "--bogus" {
|
||||
t.Fatalf("expected Param=--bogus, got %q", validationErr.Param)
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Fatalf("expected Detail.Type=unknown_flag, got %#v", exitErr.Detail)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", got, got)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation/invalid_argument, got %s/%s", problem.Category, problem.Subtype)
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected Code=ExitAPI(%d), got %d", output.ExitAPI, exitErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +177,18 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
|
||||
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-show-gridline",
|
||||
sc: SheetShowGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-hide-gridline",
|
||||
sc: SheetHideGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dropdown-set",
|
||||
sc: DropdownSet,
|
||||
|
||||
@@ -152,6 +152,12 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
|
||||
}},
|
||||
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
|
||||
"+sheet-show-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "show_gridline")
|
||||
}},
|
||||
"+sheet-hide-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "hide_gridline")
|
||||
}},
|
||||
|
||||
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
|
||||
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
|
||||
|
||||
@@ -1,4 +1,45 @@
|
||||
{
|
||||
"+undo": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "steps",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Undo the most recent N edits made through this CLI link (default 1); one step = one prior write call",
|
||||
"default": "1"
|
||||
},
|
||||
{
|
||||
"name": "rev",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Undo anchor: the document revision returned by a prior write's response (`data.revision`). Omit to undo the latest edit. Doubles as an optimistic-concurrency check — rejected if the document has moved past this revision"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-info": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
@@ -413,6 +454,86 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-hide-gridline": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-show-gridline": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-create": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
@@ -452,6 +573,25 @@
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "sheets",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool. Mutually exclusive with --headers/--values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "header-style",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Bold the typed header row (only with --sheets; default true)",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
@@ -513,6 +653,32 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-import": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "file",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Local file path (.xlsx / .xls / .csv)"
|
||||
},
|
||||
{
|
||||
"name": "folder-token",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Target folder token; imported to the cloud drive root when omitted"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Imported spreadsheet name; defaults to the local file name without its extension"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-info": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
@@ -1212,19 +1378,65 @@
|
||||
"desc": "Skip hidden rows and columns; default `false`"
|
||||
},
|
||||
{
|
||||
"name": "rows-json",
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Print the request path and parameters without executing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+table-get": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Read only this sheet (by id); omit to read all sheets"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Read only this sheet (by name); omit to read all sheets"
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "A1 range to read; omit to read each sheet current region"
|
||||
},
|
||||
{
|
||||
"name": "no-header",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false",
|
||||
"default": "false"
|
||||
"desc": "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Print the request path and parameters without executing"
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1880,6 +2092,51 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+table-put": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token to write into (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheets",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Typed table payload as JSON: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "header-style",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Bold the header row written from column names (default true)",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+cells-clear": {
|
||||
"risk": "high-risk-write",
|
||||
"flags": [
|
||||
|
||||
@@ -1787,11 +1787,7 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"+chart-update": {
|
||||
@@ -2826,11 +2822,7 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"+cond-format-create": {
|
||||
@@ -6249,6 +6241,190 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。可由 pandas DataFrame 经薄 helper 生成(NaN→null、Timestamp→ISO、numpy 标量→原生)。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"rows"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列定义,顺序与 rows 中每行的取值一一对应。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "列名(写入表头行的文本)。"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"string",
|
||||
"number",
|
||||
"date",
|
||||
"bool"
|
||||
],
|
||||
"description": "列的声明类型,显式声明、不由 CLI 猜测(避免邮编 / 订单号 / 手机号等「像数字的文本」被误判为数字)。string 列由 +table-put 自动套文本格式(number_format `@`),数字样字符串(含前导零,如「00123」)读写两侧都保真——+table-get 读回时仍判为 string、不会塌缩成数字。date 列取 ISO yyyy-mm-dd 字符串,CLI 转成 Excel 序列号 + 日期 number_format(真日期,可排序 / 透视 / 筛选)。"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"description": "可选的单元格 number_format,如 \"yyyy-mm-dd\" / \"0.00%\" / \"#,##0.00\"。percent 列的数值尺度由调用方负责(0.0469 配 \"0.00%\",helper 不自动乘 100)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 columns 数。元素按对应列的类型取值,null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值,按所在列的 type 取值(string→文本 / number→数值 / date→ISO yyyy-mm-dd 文本 / bool→布尔);null 表示空单元格。具体类型由该列在 columns 里声明的 type 决定,故此处仅约束为标量或 null。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+workbook-create": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。可由 pandas DataFrame 经薄 helper 生成(NaN→null、Timestamp→ISO、numpy 标量→原生)。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"rows"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列定义,顺序与 rows 中每行的取值一一对应。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "列名(写入表头行的文本)。"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"string",
|
||||
"number",
|
||||
"date",
|
||||
"bool"
|
||||
],
|
||||
"description": "列的声明类型,显式声明、不由 CLI 猜测(避免邮编 / 订单号 / 手机号等「像数字的文本」被误判为数字)。string 列由 +table-put 自动套文本格式(number_format `@`),数字样字符串(含前导零,如「00123」)读写两侧都保真——+table-get 读回时仍判为 string、不会塌缩成数字。date 列取 ISO yyyy-mm-dd 字符串,CLI 转成 Excel 序列号 + 日期 number_format(真日期,可排序 / 透视 / 筛选)。"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"description": "可选的单元格 number_format,如 \"yyyy-mm-dd\" / \"0.00%\" / \"#,##0.00\"。percent 列的数值尺度由调用方负责(0.0469 配 \"0.00%\",helper 不自动乘 100)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 columns 数。元素按对应列的类型取值,null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值,按所在列的 type 取值(string→文本 / number→数值 / date→ISO yyyy-mm-dd 文本 / bool→布尔);null 表示空单元格。具体类型由该列在 columns 里声明的 type 决定,故此处仅约束为标量或 null。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +308,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
|
||||
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
|
||||
},
|
||||
},
|
||||
@@ -793,6 +792,16 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-hide-gridline": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
@@ -839,6 +848,16 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-show-gridline": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-unhide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -895,6 +914,38 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+table-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
|
||||
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet current region"},
|
||||
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+table-put": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "required", Desc: "Typed table payload as JSON: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool", Input: []string{"file", "stdin"}},
|
||||
{Name: "header-style", Kind: "own", Type: "bool", Required: "optional", Desc: "Bold the header row written from column names (default true)", Default: "true"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+undo": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "steps", Kind: "own", Type: "int", Required: "optional", Desc: "Undo the most recent N edits made through this CLI link (default 1); one step = one prior write call", Default: "1"},
|
||||
{Name: "rev", Kind: "own", Type: "int", Required: "optional", Desc: "Undo anchor: the document revision returned by a prior write's response (`data.revision`). Omit to undo the latest edit. Doubles as an optimistic-concurrency check — rejected if the document has moved past this revision"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -902,6 +953,8 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool. Mutually exclusive with --headers/--values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
||||
{Name: "header-style", Kind: "own", Type: "bool", Required: "optional", Desc: "Bold the typed header row (only with --sheets; default true)", Default: "true"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -916,6 +969,14 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-import": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "file", Kind: "own", Type: "string", Required: "required", Desc: "Local file path (.xlsx / .xls / .csv)"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; imported to the cloud drive root when omitted"},
|
||||
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Imported spreadsheet name; defaults to the local file name without its extension"},
|
||||
},
|
||||
},
|
||||
"+workbook-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
|
||||
@@ -32,4 +32,6 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+range-sort": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
"+table-put": {},
|
||||
"+workbook-create": {},
|
||||
}
|
||||
|
||||
@@ -688,7 +688,7 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH
|
||||
// With a local --image, Execute first uploads the file; surface that
|
||||
// extra step in the preview (mirrors +cells-set-image's dry-run).
|
||||
if img := strings.TrimSpace(runtime.Str("image")); img != "" {
|
||||
manageBody, _ := buildToolBody("manage_float_image_object", input)
|
||||
manageBody, _ := buildToolBody(ToolKindWrite, "manage_float_image_object", input)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("upload local image to drive (parent_type=sheet_image)").
|
||||
|
||||
@@ -5,8 +5,6 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -164,12 +162,7 @@ var CsvGet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case runtime.Bool("rows-json"):
|
||||
// --rows-json reshapes the CSV response into structured rows
|
||||
// ({row_number, values:{col→cell}}); see assembleRowsJSON.
|
||||
out = assembleRowsJSON(out, strings.TrimSpace(runtime.Str("range")))
|
||||
case !runtime.Bool("include-row-prefix"):
|
||||
if !runtime.Bool("include-row-prefix") {
|
||||
out = stripRowPrefixFromCsvOutput(out)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
@@ -219,141 +212,6 @@ func stripRowPrefixFromCsvOutput(out interface{}) interface{} {
|
||||
return m
|
||||
}
|
||||
|
||||
// rowPrefixRe matches the leading "[row=N] " (or "[row=N],") annotation that
|
||||
// the tool prepends to the first physical line of each logical CSV record.
|
||||
var rowPrefixRe = regexp.MustCompile(`^\[row=(\d+)\][ ,]?`)
|
||||
|
||||
// assembleRowsJSON reshapes the tool's annotated_csv string into structured
|
||||
// rows so callers never have to regex-parse "[row=N]" or RFC-4180 CSV by hand:
|
||||
//
|
||||
// {
|
||||
// "range": "A1:K3380",
|
||||
// "current_region": "...", // passthrough, if the tool returned it
|
||||
// "rows": [{"row_number":1,"values":{"A":"姓名", ..., "K":"时间差_分钟"}},
|
||||
// {"row_number":2,"values":{"A":"张三", ..., "K":"8.5"}}, ...]
|
||||
// }
|
||||
//
|
||||
// Every logical row is emitted, including the first — no row is assumed to be a
|
||||
// header, since sheet data is not always tabular. Each cell is keyed by its
|
||||
// column letter (from the tool's col_indices when present, else derived from the
|
||||
// requested range's start column). On any parsing trouble it returns the
|
||||
// original output unchanged.
|
||||
func assembleRowsJSON(out interface{}, requestedRange string) interface{} {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
csvStr, ok := m["annotated_csv"].(string)
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
|
||||
// Group physical lines into logical records by [row=N] boundaries; lines
|
||||
// without a prefix are embedded-newline continuations of the current record.
|
||||
type logicalRow struct {
|
||||
num int
|
||||
text string
|
||||
}
|
||||
var groups []logicalRow
|
||||
for _, line := range strings.Split(csvStr, "\n") {
|
||||
if mm := rowPrefixRe.FindStringSubmatch(line); mm != nil {
|
||||
n, _ := strconv.Atoi(mm[1])
|
||||
groups = append(groups, logicalRow{num: n, text: line[len(mm[0]):]})
|
||||
} else if len(groups) > 0 {
|
||||
groups[len(groups)-1].text += "\n" + line
|
||||
}
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// Parse every logical row; widest row sets the column count. No row is
|
||||
// singled out as a header — that would assume the data is tabular, which it
|
||||
// often is not. The model reads row 1 like any other row and decides for
|
||||
// itself whether it is a header.
|
||||
parsed := make([][]string, len(groups))
|
||||
maxCols := 0
|
||||
for i, g := range groups {
|
||||
parsed[i] = parseCSVRecord(g.text)
|
||||
if len(parsed[i]) > maxCols {
|
||||
maxCols = len(parsed[i])
|
||||
}
|
||||
}
|
||||
if maxCols == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// Column letters key each cell. Prefer the tool's col_indices (authoritative,
|
||||
// length == col_count); otherwise derive from the requested range's start col.
|
||||
letters := coerceStringSlice(m["col_indices"])
|
||||
if len(letters) < maxCols {
|
||||
start := csvStartColIndex(requestedRange)
|
||||
letters = make([]string, maxCols)
|
||||
for j := 0; j < maxCols; j++ {
|
||||
letters[j] = csvColLetter(start + j)
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]map[string]interface{}, 0, len(groups))
|
||||
for i := range groups {
|
||||
fields := parsed[i]
|
||||
values := make(map[string]interface{}, len(letters))
|
||||
for j := range letters {
|
||||
v := ""
|
||||
if j < len(fields) {
|
||||
v = fields[j]
|
||||
}
|
||||
values[letters[j]] = v
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"row_number": groups[i].num,
|
||||
"values": values,
|
||||
})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{}
|
||||
for k, v := range m {
|
||||
result[k] = v
|
||||
}
|
||||
result["range"] = requestedRange
|
||||
result["rows"] = rows
|
||||
|
||||
// Surface the backend's "数据没读全" signal structurally instead of leaving it
|
||||
// buried in warning_message prose. The tool flags it when current_region (the
|
||||
// true data extent) reaches past actual_range (what was actually read) — the
|
||||
// single most important anti-under-read hint. Mirror that same comparison
|
||||
// (regionEndRow > actualEndRow) from the already-passthrough A1 ranges so the
|
||||
// model gets the real data range as a first-class field, never having to
|
||||
// parse it out of prose.
|
||||
if cr, _ := m["current_region"].(string); cr != "" {
|
||||
ar, _ := m["actual_range"].(string)
|
||||
regionEnd := a1EndRow(cr)
|
||||
readEnd := a1EndRow(ar)
|
||||
if regionEnd > 0 && readEnd > 0 && regionEnd > readEnd {
|
||||
result["data_not_fully_read"] = map[string]interface{}{
|
||||
"read_through_row": readEnd,
|
||||
"data_extends_through_row": regionEnd,
|
||||
"unread_rows": regionEnd - readEnd,
|
||||
"reread_range": cr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the fields whose information rows-json fully carries elsewhere:
|
||||
// - annotated_csv / row_indices / col_indices → reconstructed into
|
||||
// columns + rows (with integer row_number), losslessly.
|
||||
// - warning_message → its two halves are both handled: the static
|
||||
// "[row=N] / col_indices[j]" parse nag is moot once those fields exist,
|
||||
// and the dynamic "数据没读全" half is now the structured
|
||||
// data_not_fully_read field above. (Confirmed against the backend's
|
||||
// get-range-as-csv.ts — warning_message has no other content.)
|
||||
delete(result, "annotated_csv")
|
||||
delete(result, "row_indices")
|
||||
delete(result, "col_indices")
|
||||
delete(result, "warning_message")
|
||||
return result
|
||||
}
|
||||
|
||||
// a1EndRow extracts the ending row number from an A1 range, e.g. "A1:N51" → 51,
|
||||
// "Sheet1!B2:D9" → 9, "C5" → 5. Returns 0 when no row number is present.
|
||||
func a1EndRow(rng string) int {
|
||||
@@ -377,89 +235,6 @@ func a1EndRow(rng string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// parseCSVRecord parses a single logical CSV record (which may span multiple
|
||||
// physical lines via quoted embedded newlines) into its fields. An empty record
|
||||
// yields no fields; a malformed record falls back to a naive comma split so a
|
||||
// stray quote never drops a whole row.
|
||||
func parseCSVRecord(text string) []string {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
r := csv.NewReader(strings.NewReader(text))
|
||||
r.FieldsPerRecord = -1
|
||||
fields, err := r.Read()
|
||||
if err != nil {
|
||||
return strings.Split(text, ",")
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// coerceStringSlice returns v as []string when it is a homogeneous []interface{}
|
||||
// of strings (the shape of the tool's col_indices), else nil.
|
||||
func coerceStringSlice(v interface{}) []string {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
s, ok := e.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// csvStartColIndex returns the 0-based column index of a range's start column,
|
||||
// e.g. "A1:K3380" → 0, "C5:F9" → 2, "Sheet1!D2" → 3. Unparseable input → 0.
|
||||
func csvStartColIndex(rng string) int {
|
||||
rng = strings.TrimSpace(rng)
|
||||
if i := strings.LastIndex(rng, "!"); i >= 0 {
|
||||
rng = rng[i+1:]
|
||||
}
|
||||
var letters strings.Builder
|
||||
for _, c := range rng {
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') {
|
||||
letters.WriteRune(c)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if letters.Len() == 0 {
|
||||
return 0
|
||||
}
|
||||
return csvColToIndex(letters.String())
|
||||
}
|
||||
|
||||
// csvColToIndex converts a column letter to its 0-based index ("A"→0, "K"→10,
|
||||
// "AA"→26). Non-letter input → -1.
|
||||
func csvColToIndex(s string) int {
|
||||
n := 0
|
||||
for _, c := range strings.ToUpper(s) {
|
||||
if c < 'A' || c > 'Z' {
|
||||
break
|
||||
}
|
||||
n = n*26 + int(c-'A'+1)
|
||||
}
|
||||
return n - 1
|
||||
}
|
||||
|
||||
// csvColLetter converts a 0-based column index back to its letter (0→"A",
|
||||
// 25→"Z", 26→"AA"). Negative input → "".
|
||||
func csvColLetter(idx int) string {
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
var b []byte
|
||||
for idx >= 0 {
|
||||
b = append([]byte{byte('A' + idx%26)}, b...)
|
||||
idx = idx/26 - 1
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// DropdownGet wraps get_cell_ranges scoped to data_validation: read the
|
||||
// dropdown configuration on a range. Aligned with its sibling +cells-get
|
||||
// — sheet selection is via --sheet-id / --sheet-name (XOR), and --range
|
||||
|
||||
@@ -63,20 +63,6 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
"value_render_option": "formatted_value",
|
||||
},
|
||||
},
|
||||
{
|
||||
// --rows-json is post-processing on +csv-get's response; it must
|
||||
// NOT leak into the get_range_as_csv input.
|
||||
name: "+csv-get --rows-json builds the same input (flag is post-process)",
|
||||
sc: CsvGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--rows-json"},
|
||||
toolName: "get_range_as_csv",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "A1:C10",
|
||||
"max_rows": float64(unboundedReadLimit),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -179,113 +165,3 @@ func TestCsvGet_StripRowPrefix(t *testing.T) {
|
||||
t.Errorf("other field corrupted: %v", out["other"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON covers the --rows-json reshaping: every logical row
|
||||
// emitted (no header singled out), integer row_number, column-letter keyed
|
||||
// values, embedded newlines inside quoted fields, and current_region passthrough.
|
||||
func TestAssembleRowsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 姓名,备注,时间差_分钟\n[row=2] 张三,\"line1\nline2\",8.5\n[row=3] 李四,ok,3",
|
||||
"current_region": "A1:C3",
|
||||
"col_indices": []interface{}{"A", "B", "C"},
|
||||
"row_indices": []interface{}{1, 2, 3},
|
||||
"warning_message": "①定位行号…②定位列字母…",
|
||||
}
|
||||
out, ok := assembleRowsJSON(in, "A1:C3").(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("assembleRowsJSON did not return a map")
|
||||
}
|
||||
|
||||
// Fields whose info rows-json carries elsewhere are dropped (annotated_csv /
|
||||
// indices → rows; warning_message → moot static nag + structured
|
||||
// data_not_fully_read). Unrelated metadata like current_region is preserved.
|
||||
if _, exists := out["annotated_csv"]; exists {
|
||||
t.Errorf("annotated_csv should be dropped")
|
||||
}
|
||||
if _, exists := out["col_indices"]; exists {
|
||||
t.Errorf("col_indices should be dropped")
|
||||
}
|
||||
if _, exists := out["warning_message"]; exists {
|
||||
t.Errorf("warning_message should be dropped in rows-json mode")
|
||||
}
|
||||
if _, exists := out["columns"]; exists {
|
||||
t.Errorf("columns field should not exist (no header assumption)")
|
||||
}
|
||||
if out["current_region"] != "A1:C3" {
|
||||
t.Errorf("current_region passthrough lost: %v", out["current_region"])
|
||||
}
|
||||
|
||||
rows, _ := out["rows"].([]map[string]interface{})
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("want all 3 rows (incl. row 1), got %d: %+v", len(rows), rows)
|
||||
}
|
||||
// Row 1 is emitted as a normal row, not consumed as a header.
|
||||
if rows[0]["row_number"].(int) != 1 {
|
||||
t.Errorf("first row_number = %v, want 1", rows[0]["row_number"])
|
||||
}
|
||||
if v := rows[0]["values"].(map[string]interface{}); v["A"] != "姓名" || v["C"] != "时间差_分钟" {
|
||||
t.Errorf("row 1 values wrong: %+v", v)
|
||||
}
|
||||
// Row 2 keeps its embedded newline inside a single cell.
|
||||
v1 := rows[1]["values"].(map[string]interface{})
|
||||
if rows[1]["row_number"].(int) != 2 || v1["A"] != "张三" || v1["B"] != "line1\nline2" || v1["C"] != "8.5" {
|
||||
t.Errorf("row 2 wrong (embedded newline?): %+v", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON_DerivedLetters verifies cell letters are derived from the
|
||||
// range start when the tool omits col_indices (e.g. a C-anchored read).
|
||||
func TestAssembleRowsJSON_DerivedLetters(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=5] h1,h2\n[row=6] a,b",
|
||||
}
|
||||
out := assembleRowsJSON(in, "C5:D6").(map[string]interface{})
|
||||
rows := out["rows"].([]map[string]interface{})
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("want 2 rows, got %d", len(rows))
|
||||
}
|
||||
if rows[0]["row_number"].(int) != 5 {
|
||||
t.Errorf("first row_number = %v, want 5", rows[0]["row_number"])
|
||||
}
|
||||
if v := rows[0]["values"].(map[string]interface{}); v["C"] != "h1" || v["D"] != "h2" {
|
||||
t.Errorf("derived-letter values wrong: %+v", v)
|
||||
}
|
||||
if v := rows[1]["values"].(map[string]interface{}); v["C"] != "a" || v["D"] != "b" {
|
||||
t.Errorf("row 6 values wrong: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON_DataNotFullyRead verifies the structured under-read hint:
|
||||
// when current_region extends past actual_range, rows-json surfaces the true data
|
||||
// range as a first-class field (mirroring the backend's prose warning).
|
||||
func TestAssembleRowsJSON_DataNotFullyRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Read only A1:D2, but the data region reaches D4 → 2 rows unread.
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
|
||||
"actual_range": "A1:D2",
|
||||
"current_region": "A1:D4",
|
||||
}
|
||||
out := assembleRowsJSON(in, "A1:D2").(map[string]interface{})
|
||||
hint, ok := out["data_not_fully_read"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data_not_fully_read missing; out=%+v", out)
|
||||
}
|
||||
if hint["read_through_row"] != 2 || hint["data_extends_through_row"] != 4 ||
|
||||
hint["unread_rows"] != 2 || hint["reread_range"] != "A1:D4" {
|
||||
t.Errorf("data_not_fully_read wrong: %+v", hint)
|
||||
}
|
||||
|
||||
// Fully-read case: no hint emitted.
|
||||
in2 := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
|
||||
"actual_range": "A1:D2",
|
||||
"current_region": "A1:D2",
|
||||
}
|
||||
out2 := assembleRowsJSON(in2, "A1:D2").(map[string]interface{})
|
||||
if _, exists := out2["data_not_fully_read"]; exists {
|
||||
t.Errorf("data_not_fully_read should be absent when fully read")
|
||||
}
|
||||
}
|
||||
|
||||
1148
shortcuts/sheets/lark_sheet_table_io.go
Normal file
1148
shortcuts/sheets/lark_sheet_table_io.go
Normal file
File diff suppressed because it is too large
Load Diff
991
shortcuts/sheets/lark_sheet_table_io_test.go
Normal file
991
shortcuts/sheets/lark_sheet_table_io_test.go
Normal file
@@ -0,0 +1,991 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// ─── pure helpers: date serial, typed cell mapping ────────────────────
|
||||
|
||||
func TestTablePut_IsoDateToSerial(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{"2024-01-15", 45306, true}, // the empirically verified anchor
|
||||
{"2024-01-01", 45292, true},
|
||||
{"2024-02-29", 45351, true}, // 2024 is a leap year
|
||||
{"1899-12-31", 1, true}, // one day after the epoch
|
||||
{"not-a-date", 0, false},
|
||||
{"2024/01/15", 0, false}, // wrong separator
|
||||
}
|
||||
for _, tt := range cases {
|
||||
got, err := isoDateToSerial(tt.in)
|
||||
if tt.ok {
|
||||
if err != nil {
|
||||
t.Errorf("isoDateToSerial(%q) unexpected error: %v", tt.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("isoDateToSerial(%q) = %d, want %d", tt.in, got, tt.want)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Errorf("isoDateToSerial(%q) = %d, want error", tt.in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablePut_BuildTypedCell(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("string keeps literal + text format so digit-like ids survive read-back", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell, err := buildTypedCell(&tableColumnSpec{Name: "id", Type: "string"}, "00123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cell["value"] != "00123" {
|
||||
t.Errorf("value = %#v, want \"00123\"", cell["value"])
|
||||
}
|
||||
if nf := numberFormatOf(cell); nf != "@" {
|
||||
t.Errorf("number_format = %q, want @ (text format so +table-get infers string, not number)", nf)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string stringifies a json.Number without scientific notation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell, _ := buildTypedCell(&tableColumnSpec{Name: "code", Type: "string"}, json.Number("123456789012345"))
|
||||
if cell["value"] != "123456789012345" {
|
||||
t.Errorf("value = %#v, want literal digits", cell["value"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("number preserves json.Number", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell, err := buildTypedCell(&tableColumnSpec{Name: "amt", Type: "number", Format: "#,##0"}, json.Number("259874"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, ok := cell["value"].(json.Number); !ok || n.String() != "259874" {
|
||||
t.Errorf("value = %#v, want json.Number 259874", cell["value"])
|
||||
}
|
||||
if nf := numberFormatOf(cell); nf != "#,##0" {
|
||||
t.Errorf("number_format = %q, want #,##0", nf)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date converts to serial + default format", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell, err := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date"}, "2024-01-15")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cell["value"] != 45306 {
|
||||
t.Errorf("value = %#v, want serial 45306", cell["value"])
|
||||
}
|
||||
if nf := numberFormatOf(cell); nf != "yyyy-mm-dd" {
|
||||
t.Errorf("number_format = %q, want default yyyy-mm-dd", nf)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date honors explicit format", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell, _ := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date", Format: "yyyy-mm"}, "2024-01-15")
|
||||
if nf := numberFormatOf(cell); nf != "yyyy-mm" {
|
||||
t.Errorf("number_format = %q, want yyyy-mm", nf)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bool maps to boolean", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell, err := buildTypedCell(&tableColumnSpec{Name: "b", Type: "bool"}, true)
|
||||
if err != nil || cell["value"] != true {
|
||||
t.Errorf("value = %#v (err=%v), want true", cell["value"], err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("null is an empty cell that still carries format", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell, err := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, has := cell["value"]; has {
|
||||
t.Errorf("null cell should have no value: %#v", cell)
|
||||
}
|
||||
if nf := numberFormatOf(cell); nf != "yyyy-mm-dd" {
|
||||
t.Errorf("null date cell should still carry format, got %q", nf)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type mismatches are rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := buildTypedCell(&tableColumnSpec{Type: "number"}, "abc"); err == nil {
|
||||
t.Error("number column accepting a string should error")
|
||||
}
|
||||
if _, err := buildTypedCell(&tableColumnSpec{Type: "date"}, json.Number("1")); err == nil {
|
||||
t.Error("date column accepting a number should error")
|
||||
}
|
||||
if _, err := buildTypedCell(&tableColumnSpec{Type: "bool"}, "true"); err == nil {
|
||||
t.Error("bool column accepting a string should error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// numberFormatOf digs the number_format out of a built cell's cell_styles, or
|
||||
// "" when absent.
|
||||
func numberFormatOf(cell map[string]interface{}) string {
|
||||
styles, ok := cell["cell_styles"].(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
nf, _ := styles["number_format"].(string)
|
||||
return nf
|
||||
}
|
||||
|
||||
// ─── payload validation ───────────────────────────────────────────────
|
||||
|
||||
func TestTablePut_PayloadValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
json string
|
||||
want string
|
||||
}{
|
||||
{"empty sheets", `{"sheets":[]}`, "at least one sheet"},
|
||||
{"missing name", `{"sheets":[{"columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "name is required"},
|
||||
{"duplicate name", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[]},{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "duplicate sheet name"},
|
||||
{"no columns", `{"sheets":[{"name":"S","columns":[],"rows":[]}]}`, "columns must be non-empty"},
|
||||
{"bad column type", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"timestamp"}],"rows":[]}]}`, "invalid type"},
|
||||
{"column missing name", `{"sheets":[{"name":"S","columns":[{"type":"string"}],"rows":[]}]}`, "columns[0].name is required"},
|
||||
{"row width mismatch", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"},{"name":"b","type":"string"}],"rows":[["x"]]}]}`, "column count"},
|
||||
{"bad start_cell", `{"sheets":[{"name":"S","start_cell":"A","columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "start_cell"},
|
||||
{"bad date value", `{"sheets":[{"name":"S","columns":[{"name":"d","type":"date"}],"rows":[["2025/03/31"]]}]}`, "must be ISO"},
|
||||
{"number expects numeric", `{"sheets":[{"name":"S","columns":[{"name":"n","type":"number"}],"rows":[["abc"]]}]}`, "number expects"},
|
||||
{"invalid json", `{not json`, "invalid JSON"},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseTablePutPayload(stubFlagView{"sheets": tt.json})
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Errorf("want error containing %q, got %v", tt.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stubFlagView is a minimal flagView backed by a map, for unit-testing the
|
||||
// payload parser without a cobra command.
|
||||
type stubFlagView map[string]string
|
||||
|
||||
func (s stubFlagView) Str(name string) string { return s[name] }
|
||||
func (s stubFlagView) Bool(name string) bool { return s[name] == "true" }
|
||||
func (s stubFlagView) Int(name string) int { return 0 }
|
||||
func (s stubFlagView) Float64(name string) float64 { return 0 }
|
||||
func (s stubFlagView) Changed(name string) bool { _, ok := s[name]; return ok }
|
||||
func (s stubFlagView) StrArray(name string) []string { return nil }
|
||||
func (s stubFlagView) StrSlice(name string) []string { return nil }
|
||||
func (s stubFlagView) Command() string { return "+table-put" }
|
||||
|
||||
// ─── dry-run: create + write rendering ────────────────────────────────
|
||||
|
||||
const tablePutSheetsJSON = `{"sheets":[{"name":"月度","columns":[` +
|
||||
`{"name":"门店","type":"string"},` +
|
||||
`{"name":"月份","type":"date","format":"yyyy-mm"},` +
|
||||
`{"name":"销售额","type":"number","format":"#,##0"}` +
|
||||
`],"rows":[["北京","2024-01-15",259874]]}]}`
|
||||
|
||||
func TestTablePut_DryRunWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL, "--sheets", tablePutSheetsJSON})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1 (set_cell_range only)", len(calls))
|
||||
}
|
||||
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
if input["excel_id"] != testToken {
|
||||
t.Errorf("excel_id = %v, want %s", input["excel_id"], testToken)
|
||||
}
|
||||
if input["sheet_name"] != "月度" {
|
||||
t.Errorf("sheet_name = %v, want 月度", input["sheet_name"])
|
||||
}
|
||||
if input["range"] != "A1:C2" {
|
||||
t.Errorf("range = %v, want A1:C2 (1 header + 1 data row × 3 cols)", input["range"])
|
||||
}
|
||||
rows := input["cells"].([]interface{})
|
||||
header := rows[0].([]interface{})
|
||||
if hs := cellStyles(header[0]); hs["font_weight"] != "bold" {
|
||||
t.Errorf("header cell should be bold, got %#v", header[0])
|
||||
}
|
||||
data := rows[1].([]interface{})
|
||||
// 月份 (date) → serial 45306, number_format yyyy-mm
|
||||
if v := cellValue(data[1]); v != float64(45306) {
|
||||
t.Errorf("date cell value = %#v, want 45306 serial", v)
|
||||
}
|
||||
if nf := cellStyles(data[1])["number_format"]; nf != "yyyy-mm" {
|
||||
t.Errorf("date number_format = %v, want yyyy-mm", nf)
|
||||
}
|
||||
// 销售额 (number) → 259874 preserved
|
||||
if v := cellValue(data[2]); v != float64(259874) {
|
||||
t.Errorf("number cell value = %#v, want 259874", v)
|
||||
}
|
||||
}
|
||||
|
||||
func cellValue(c interface{}) interface{} {
|
||||
m, _ := c.(map[string]interface{})
|
||||
return m["value"]
|
||||
}
|
||||
|
||||
func cellStyles(c interface{}) map[string]interface{} {
|
||||
m, _ := c.(map[string]interface{})
|
||||
s, _ := m["cell_styles"].(map[string]interface{})
|
||||
return s
|
||||
}
|
||||
|
||||
// ─── validation through the cobra surface ─────────────────────────────
|
||||
|
||||
func TestTablePut_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing spreadsheet locator rejected",
|
||||
args: []string{"--sheets", tablePutSheetsJSON},
|
||||
want: "at least one",
|
||||
},
|
||||
{
|
||||
name: "url and token are mutually exclusive",
|
||||
args: []string{"--url", testURL, "--spreadsheet-token", testToken, "--sheets", tablePutSheetsJSON},
|
||||
want: "mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "bad column type rejected",
|
||||
args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"foo"}],"rows":[]}]}`},
|
||||
want: "invalid type",
|
||||
},
|
||||
{
|
||||
name: "row width mismatch rejected",
|
||||
args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"},{"name":"b","type":"string"}],"rows":[["only-one"]]}]}`},
|
||||
want: "column count",
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, TablePut, append(tt.args, "--dry-run"))
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; got nil. stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), tt.want) {
|
||||
t.Errorf("error missing %q; got=%s|%s|%v", tt.want, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── execute paths with stubbed tools ─────────────────────────────────
|
||||
|
||||
// TestTablePut_ExecuteWrite drives the write path: a structure read maps the
|
||||
// existing sheet by name, then a set_cell_range write fills it.
|
||||
func TestTablePut_ExecuteWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"数据","index":0}]}`)
|
||||
write := toolOutputStub(testToken, "write", `{"updated_cells_count":2}`)
|
||||
out, err := runShortcutWithStubs(t, TablePut,
|
||||
[]string{"--url", testURL, "--sheets",
|
||||
`{"sheets":[{"name":"数据","columns":[{"name":"a","type":"string"},{"name":"b","type":"number"}],"rows":[["x",1]]}]}`},
|
||||
structure, write)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
sheets, _ := data["sheets"].([]interface{})
|
||||
if len(sheets) != 1 {
|
||||
t.Fatalf("result sheets = %d, want 1: %#v", len(sheets), data)
|
||||
}
|
||||
s0, _ := sheets[0].(map[string]interface{})
|
||||
if s0["name"] != "数据" || s0["sheet_id"] != testSheetID {
|
||||
t.Errorf("sheet summary = %#v, want name=数据 sheet_id=%s", s0, testSheetID)
|
||||
}
|
||||
if s0["range"] != "A1:B2" {
|
||||
t.Errorf("range = %v, want A1:B2", s0["range"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_ExecuteWriteCreatesMissingSheet covers the branch where the
|
||||
// named sheet does not yet exist: a create precedes the write.
|
||||
func TestTablePut_ExecuteWriteCreatesMissingSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
// First structure read sees only "Sheet1"; the payload targets "新表", so
|
||||
// createSheet runs, and the follow-up read (FIFO: second stub) resolves the
|
||||
// newly created sheet's id.
|
||||
structBefore := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0}]}`)
|
||||
structAfter := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"新表","index":1}]}`)
|
||||
write := toolOutputStub(testToken, "write", `{"ok":true}`)
|
||||
write.Reusable = true // modify_workbook_structure create + set_cell_range
|
||||
out, err := runShortcutWithStubs(t, TablePut,
|
||||
[]string{"--url", testURL, "--sheets",
|
||||
`{"sheets":[{"name":"新表","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`},
|
||||
structBefore, structAfter, write)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
sheets, _ := data["sheets"].([]interface{})
|
||||
if len(sheets) != 1 {
|
||||
t.Fatalf("result sheets = %d, want 1", len(sheets))
|
||||
}
|
||||
if s0, _ := sheets[0].(map[string]interface{}); s0["sheet_id"] != testSheetID2 {
|
||||
t.Errorf("created sheet id = %v, want %s", s0["sheet_id"], testSheetID2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_SheetCreateDims checks new-sheet sizing: small tables keep the
|
||||
// 20×200 floor (unchanged behavior), wide/long tables grow past it (the fix for
|
||||
// set_cell_range "exceeds sheet bounds"), and start_cell offset + header row are
|
||||
// accounted for, with columns clamped to the backend's 200 ceiling.
|
||||
func TestTablePut_SheetCreateDims(t *testing.T) {
|
||||
t.Parallel()
|
||||
bp := func(b bool) *bool { return &b }
|
||||
cols := func(n int) []tableColumnSpec { return make([]tableColumnSpec, n) }
|
||||
rows := func(n int) [][]interface{} { return make([][]interface{}, n) }
|
||||
cases := []struct {
|
||||
name string
|
||||
spec tableSheetSpec
|
||||
wantRows, wantCols int
|
||||
}{
|
||||
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, 200, 20},
|
||||
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, 200, 37},
|
||||
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, 501, 20},
|
||||
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, 200, 42},
|
||||
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, 500, 20},
|
||||
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, 200, 200},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gotRows, gotCols := sheetCreateDims(&tt.spec)
|
||||
if gotRows != tt.wantRows || gotCols != tt.wantCols {
|
||||
t.Errorf("sheetCreateDims = (%d rows, %d cols), want (%d, %d)", gotRows, gotCols, tt.wantRows, tt.wantCols)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_ExecuteCreatesWideSheetWithDims is the regression test for the
|
||||
// wide-table bug: a 25-column payload targeting a not-yet-existing sheet must
|
||||
// create it with 25 columns (past the 20-column default) so the follow-up
|
||||
// set_cell_range fits instead of failing with "exceeds sheet bounds".
|
||||
func TestTablePut_ExecuteCreatesWideSheetWithDims(t *testing.T) {
|
||||
t.Parallel()
|
||||
structBefore := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0}]}`)
|
||||
createStub := toolOutputStub(testToken, "write", `{"ok":true}`) // modify_workbook_structure create
|
||||
structAfter := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"宽表","index":1}]}`)
|
||||
writeStub := toolOutputStub(testToken, "write", `{"ok":true}`) // set_cell_range
|
||||
const n = 25
|
||||
cols := strings.TrimRight(strings.Repeat(`{"name":"c","type":"string"},`, n), ",")
|
||||
vals := strings.TrimRight(strings.Repeat(`"x",`, n), ",")
|
||||
payload := `{"sheets":[{"name":"宽表","columns":[` + cols + `],"rows":[[` + vals + `]]}]}`
|
||||
out, err := runShortcutWithStubs(t, TablePut,
|
||||
[]string{"--url", testURL, "--sheets", payload},
|
||||
structBefore, createStub, structAfter, writeStub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
var wire map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &wire); err != nil {
|
||||
t.Fatalf("decode create body: %v", err)
|
||||
}
|
||||
var input map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
|
||||
t.Fatalf("decode create tool input: %v", err)
|
||||
}
|
||||
if input["operation"] != "create" {
|
||||
t.Fatalf("first write should be the create op, got %#v", input["operation"])
|
||||
}
|
||||
if input["columns"] != float64(n) {
|
||||
t.Errorf("create columns = %#v, want %d (sized to the wide payload)", input["columns"], n)
|
||||
}
|
||||
if input["rows"] != float64(200) {
|
||||
t.Errorf("create rows = %#v, want 200 (floor)", input["rows"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_ExecutePartialFailure covers the partial-success error path:
|
||||
// a set_cell_range write fails mid-import and the structured error surfaces.
|
||||
// TestTablePut_ExecuteTotalFailure: a single sheet whose write fails landed
|
||||
// nothing — it must be a plain failure, NOT partial_success.
|
||||
func TestTablePut_ExecuteTotalFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"数据","index":0}]}`)
|
||||
writeErr := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_write",
|
||||
Body: map[string]interface{}{"code": 1254000, "msg": "boom"},
|
||||
}
|
||||
out, err := runShortcutWithStubs(t, TablePut,
|
||||
[]string{"--url", testURL, "--sheets",
|
||||
`{"sheets":[{"name":"数据","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`},
|
||||
structure, writeErr)
|
||||
if err == nil {
|
||||
t.Fatalf("expected failure; got nil. out=%s", out)
|
||||
}
|
||||
if strings.Contains(err.Error(), "partially applied") || strings.Contains(out, "partially applied") {
|
||||
t.Errorf("single-sheet failure must NOT be partial_success; got err=%v out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed") && !strings.Contains(out, "no sheets were written") {
|
||||
t.Errorf("expected plain-failure message; got err=%v out=%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_ExecutePartialFailure: first sheet's write lands, second fails →
|
||||
// partial_success carrying the first sheet in written_sheets.
|
||||
func TestTablePut_ExecutePartialFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
structure := toolOutputStub(testToken, "read",
|
||||
`{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"汇总","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"明细","index":1}]}`)
|
||||
writeOK := toolOutputStub(testToken, "write", `{"updated_cells_count":2}`)
|
||||
writeErr := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_write",
|
||||
Body: map[string]interface{}{"code": 1254000, "msg": "boom"},
|
||||
}
|
||||
out, err := runShortcutWithStubs(t, TablePut,
|
||||
[]string{"--url", testURL, "--sheets",
|
||||
`{"sheets":[{"name":"汇总","columns":[{"name":"a","type":"string"}],"rows":[["x"]]},{"name":"明细","columns":[{"name":"a","type":"string"}],"rows":[["y"]]}]}`},
|
||||
structure, writeOK, writeErr)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial-success error; got nil. out=%s", out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "partially applied") && !strings.Contains(out, "partially applied") {
|
||||
t.Errorf("expected partial_success (not total failure); got err=%v out=%s", err, out)
|
||||
}
|
||||
// The failing sheet is named in the message; the written one lives in the
|
||||
// structured written_sheets detail.
|
||||
if !strings.Contains(err.Error(), "明细") {
|
||||
t.Errorf("partial_success should name the failed sheet 明细; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── +workbook-create typed --sheets path ─────────────────────────────
|
||||
|
||||
// TestWorkbookCreate_TypedMutualExclusion locks the Validate contract: the typed
|
||||
// --sheets entry can't be combined with the untyped --headers/--values.
|
||||
func TestWorkbookCreate_TypedMutualExclusion(t *testing.T) {
|
||||
t.Parallel()
|
||||
typed := `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"sheets+headers", []string{"--title", "X", "--sheets", typed, "--headers", `["a"]`}},
|
||||
{"sheets+values", []string{"--title", "X", "--sheets", typed, "--values", `[["x"]]`}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, tc.args)
|
||||
if err == nil {
|
||||
t.Fatalf("expected mutual-exclusion error; got nil (stderr=%s)", stderr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("want 'mutually exclusive' error; got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookCreate_EmptySheetsErrors locks the fix for an explicitly-given but
|
||||
// empty --sheets (e.g. empty stdin / file): it must error, not silently fall
|
||||
// through to creating an empty workbook.
|
||||
func TestWorkbookCreate_EmptySheetsErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, []string{"--title", "X", "--sheets", ""})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for empty --sheets; got nil (stderr=%s)", stderr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty") {
|
||||
t.Errorf("want 'empty' error; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookCreate_TypedAdoptsDefaultSheet covers the one-step typed create:
|
||||
// the new workbook's default sheet is renamed to the first payload sheet's name
|
||||
// and reused (no empty Sheet1 left behind), then written type-faithfully (the
|
||||
// date lands as an Excel serial, not text).
|
||||
func TestWorkbookCreate_TypedAdoptsDefaultSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
create := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"spreadsheet": map[string]interface{}{"spreadsheet_token": "shtTYPED", "title": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
// lookupFirstSheetID and writeTypedSheets' listSheetIDsByName both read the
|
||||
// structure; one reusable stub serves both, reporting only the default sheet.
|
||||
structure := toolOutputStub("shtTYPED", "read", `{"sheets":[{"sheet_id":"shtDef","sheet_name":"Sheet1","index":0}]}`)
|
||||
structure.Reusable = true
|
||||
rename := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheet_ai/v2/spreadsheets/shtTYPED/tools/invoke_write",
|
||||
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), "modify_workbook_structure") },
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"output": `{"ok":true}`}},
|
||||
}
|
||||
write := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheet_ai/v2/spreadsheets/shtTYPED/tools/invoke_write",
|
||||
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), "set_cell_range") },
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"output": `{"updated_cells_count":4}`}},
|
||||
}
|
||||
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
|
||||
"--title", "Demo",
|
||||
"--sheets", `{"sheets":[{"name":"Sales","columns":[{"name":"d","type":"date"},{"name":"amt","type":"number"}],"rows":[["2024-01-15",1234.5]]}]}`,
|
||||
}, create, structure, rename, write)
|
||||
if err != nil {
|
||||
t.Fatalf("typed create failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtTYPED" {
|
||||
t.Errorf("spreadsheet_token = %v, want shtTYPED", data["spreadsheet"])
|
||||
}
|
||||
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
|
||||
t.Fatalf("want 1 written sheet, got %#v", data["sheets"])
|
||||
}
|
||||
// Default sheet adopted: rename targets shtDef → "Sales" (no new sheet, no
|
||||
// stray Sheet1).
|
||||
renameInput := decodeToolInput(t, decodeRawEnvelopeBody(t, rename.CapturedBody), "modify_workbook_structure")
|
||||
if renameInput["operation"] != "rename" || renameInput["sheet_id"] != "shtDef" || renameInput["new_name"] != "Sales" {
|
||||
t.Errorf("rename should adopt default shtDef→Sales; got %#v", renameInput)
|
||||
}
|
||||
// The data write carries the date as serial 45306, proving the type-faithful path.
|
||||
writeInput := decodeToolInput(t, decodeRawEnvelopeBody(t, write.CapturedBody), "set_cell_range")
|
||||
cellsJSON, _ := json.Marshal(writeInput["cells"])
|
||||
if !strings.Contains(string(cellsJSON), "45306") {
|
||||
t.Errorf("date 2024-01-15 should be written as serial 45306; cells=%s", cellsJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookCreate_TypedDryRun verifies the dry-run previews create + a typed
|
||||
// set_cell_range write with the date already converted to a serial.
|
||||
func TestWorkbookCreate_TypedDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Demo",
|
||||
"--sheets", `{"sheets":[{"name":"S","columns":[{"name":"d","type":"date"}],"rows":[["2024-01-15"]]}]}`,
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("want 2 dry-run calls (create + typed write), got %d", len(calls))
|
||||
}
|
||||
raw, _ := json.Marshal(calls[1])
|
||||
if !strings.Contains(string(raw), "45306") {
|
||||
t.Errorf("typed dry-run write should contain serial 45306; got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablePut_StringifyCellValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in interface{}
|
||||
want string
|
||||
}{
|
||||
{"plain", "plain"},
|
||||
{json.Number("12345678901234"), "12345678901234"},
|
||||
{true, "TRUE"},
|
||||
{false, "FALSE"},
|
||||
{3.5, "3.5"},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
if got := stringifyCellValue(tt.in); got != tt.want {
|
||||
t.Errorf("stringifyCellValue(%#v) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablePut_DescribeJSONType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in interface{}
|
||||
want string
|
||||
}{
|
||||
{"x", "a string"},
|
||||
{json.Number("1"), "a number"},
|
||||
{true, "a boolean"},
|
||||
{[]interface{}{}, "an array"},
|
||||
{map[string]interface{}{}, "an object"},
|
||||
{3.14, "float64"},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
if got := describeJSONType(tt.in); got != tt.want {
|
||||
t.Errorf("describeJSONType(%#v) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablePut_HeaderAndMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
bp := func(b bool) *bool { return &b }
|
||||
// headerOn: overwrite writes header, append omits it by default, explicit wins
|
||||
if !headerOn(&tableSheetSpec{}) {
|
||||
t.Error("overwrite default should write header")
|
||||
}
|
||||
if headerOn(&tableSheetSpec{Mode: "append"}) {
|
||||
t.Error("append should omit header by default")
|
||||
}
|
||||
if !headerOn(&tableSheetSpec{Mode: "append", Header: bp(true)}) {
|
||||
t.Error("explicit header:true should override append default")
|
||||
}
|
||||
if headerOn(&tableSheetSpec{Header: bp(false)}) {
|
||||
t.Error("explicit header:false should be honored")
|
||||
}
|
||||
// writeModeName
|
||||
if writeModeName(&tableSheetSpec{}) != "overwrite" || writeModeName(&tableSheetSpec{Mode: "append"}) != "append" {
|
||||
t.Error("writeModeName normalization wrong")
|
||||
}
|
||||
// buildSheetMatrix header toggle
|
||||
s := &tableSheetSpec{Columns: []tableColumnSpec{{Name: "a", Type: "string"}}, Rows: [][]interface{}{{"x"}}}
|
||||
if m, _ := buildSheetMatrix(s, true, false); len(m) != 1 {
|
||||
t.Errorf("header off → 1 data row, got %d", len(m))
|
||||
}
|
||||
if m, _ := buildSheetMatrix(s, true, true); len(m) != 2 {
|
||||
t.Errorf("header on → header + 1 data row, got %d", len(m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablePut_BadModeRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseTablePutPayload(stubFlagView{"sheets": `{"sheets":[{"name":"S","mode":"upsert","columns":[{"name":"a","type":"string"}],"rows":[]}]}`})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid") {
|
||||
t.Errorf("mode \"upsert\" should be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_AppendEmptySheetWritesHeader: appending to an EMPTY sheet still
|
||||
// writes the header row, so column names aren't lost (and a later +table-get
|
||||
// won't consume the first data row as the header).
|
||||
func TestTablePut_AppendEmptySheetWritesHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"新","index":0}]}`)
|
||||
region := toolOutputStub(testToken, "read", `{}`) // empty sheet: no current_region → lastRow 0
|
||||
write := toolOutputStub(testToken, "write", `{"ok":true}`)
|
||||
out, err := runShortcutWithStubs(t, TablePut,
|
||||
[]string{"--url", testURL, "--sheets",
|
||||
`{"sheets":[{"name":"新","mode":"append","columns":[{"name":"列A","type":"string"}],"rows":[["x"],["y"]]}]}`},
|
||||
structure, region, write)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
var wire map[string]interface{}
|
||||
if err := json.Unmarshal(write.CapturedBody, &wire); err != nil {
|
||||
t.Fatalf("decode captured write body: %v", err)
|
||||
}
|
||||
var input map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
|
||||
t.Fatalf("decode tool input: %v", err)
|
||||
}
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
if len(cells) != 3 {
|
||||
t.Fatalf("empty-sheet append should write header + 2 data rows = 3, got %d", len(cells))
|
||||
}
|
||||
if header, _ := cells[0].([]interface{}); len(header) > 0 {
|
||||
if h0, _ := header[0].(map[string]interface{}); h0["value"] != "列A" {
|
||||
t.Errorf("first row should be the header 列A; got %#v", h0)
|
||||
}
|
||||
}
|
||||
if input["range"] != "A1:A3" {
|
||||
t.Errorf("range = %v, want A1:A3 (header + 2 rows at top of empty sheet)", input["range"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_ExecuteAppend verifies append placement: data lands below the
|
||||
// sheet's existing data (current_region A1:B5 → start at row 6) with no repeated
|
||||
// header.
|
||||
func TestTablePut_ExecuteAppend(t *testing.T) {
|
||||
t.Parallel()
|
||||
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"日志","index":0}]}`)
|
||||
region := toolOutputStub(testToken, "read", `{"current_region":"A1:B5","actual_range":"A1:B5"}`)
|
||||
write := toolOutputStub(testToken, "write", `{"ok":true}`)
|
||||
out, err := runShortcutWithStubs(t, TablePut,
|
||||
[]string{"--url", testURL, "--sheets",
|
||||
`{"sheets":[{"name":"日志","mode":"append","columns":[{"name":"时间","type":"string"},{"name":"值","type":"number"}],"rows":[["t1",1],["t2",2]]}]}`},
|
||||
structure, region, write)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
// inspect the set_cell_range request the append produced
|
||||
var wire map[string]interface{}
|
||||
if err := json.Unmarshal(write.CapturedBody, &wire); err != nil {
|
||||
t.Fatalf("decode captured write body: %v", err)
|
||||
}
|
||||
var input map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
|
||||
t.Fatalf("decode tool input: %v", err)
|
||||
}
|
||||
if input["range"] != "A6:B7" {
|
||||
t.Errorf("append range = %v, want A6:B7 (2 rows below last data row 5, no header)", input["range"])
|
||||
}
|
||||
if cells, _ := input["cells"].([]interface{}); len(cells) != 2 {
|
||||
t.Errorf("append should write 2 data rows (no header), got %d", len(cells))
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if s0, _ := data["sheets"].([]interface{})[0].(map[string]interface{}); s0["mode"] != "append" {
|
||||
t.Errorf("summary mode = %v, want append", s0["mode"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_HeaderFalseAndAllowOverwrite checks header:false drops the
|
||||
// header row and allow_overwrite:false reaches the tool input.
|
||||
func TestTablePut_HeaderFalseAndAllowOverwrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL, "--sheets",
|
||||
`{"sheets":[{"name":"S","header":false,"allow_overwrite":false,"columns":[{"name":"a","type":"string"}],"rows":[["x"],["y"]]}]}`})
|
||||
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
if input["allow_overwrite"] != false {
|
||||
t.Errorf("allow_overwrite = %v, want false", input["allow_overwrite"])
|
||||
}
|
||||
rows, _ := input["cells"].([]interface{})
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("header:false → 2 data rows only, got %d", len(rows))
|
||||
}
|
||||
first, _ := rows[0].([]interface{})[0].(map[string]interface{})
|
||||
if first["value"] != "x" {
|
||||
t.Errorf("header:false first cell = %v, want data 'x' (no header row)", first["value"])
|
||||
}
|
||||
}
|
||||
|
||||
// ─── +table-get ───────────────────────────────────────────────────────
|
||||
|
||||
func TestTableGet_SerialRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, iso := range []string{"2024-01-15", "2024-02-29", "2000-01-01", "1899-12-31"} {
|
||||
s, err := isoDateToSerial(iso)
|
||||
if err != nil {
|
||||
t.Fatalf("isoDateToSerial(%s): %v", iso, err)
|
||||
}
|
||||
if back := serialToISO(float64(s)); back != iso {
|
||||
t.Errorf("roundtrip %s → %d → %s", iso, s, back)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableGet_IsDateNumberFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, nf := range []string{"yyyy-mm-dd", "yyyy-mm", "yyyy/m/d", "YYYY/MM/DD"} {
|
||||
if !isDateNumberFormat(nf) {
|
||||
t.Errorf("%q should be a date format", nf)
|
||||
}
|
||||
}
|
||||
for _, nf := range []string{"#,##0", "0.00", "0.00%", "@", ""} {
|
||||
if isDateNumberFormat(nf) {
|
||||
t.Errorf("%q should not be a date format", nf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableGet_InferColumnType(t *testing.T) {
|
||||
t.Parallel()
|
||||
mk := func(v interface{}, nf string) map[string]interface{} {
|
||||
c := map[string]interface{}{"value": v}
|
||||
if nf != "" {
|
||||
c["cell_styles"] = map[string]interface{}{"number_format": nf}
|
||||
}
|
||||
return c
|
||||
}
|
||||
col := func(cells ...map[string]interface{}) [][]map[string]interface{} {
|
||||
rows := make([][]map[string]interface{}, len(cells))
|
||||
for i, c := range cells {
|
||||
rows[i] = []map[string]interface{}{c}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
if typ, f := inferColumnType(col(mk(45306.0, "yyyy-mm-dd")), 0); typ != "date" || f != "yyyy-mm-dd" {
|
||||
t.Errorf("date col → %s/%s", typ, f)
|
||||
}
|
||||
if typ, f := inferColumnType(col(mk(100.0, "#,##0")), 0); typ != "number" || f != "#,##0" {
|
||||
t.Errorf("number col → %s/%s", typ, f)
|
||||
}
|
||||
if typ, _ := inferColumnType(col(mk(true, "")), 0); typ != "bool" {
|
||||
t.Errorf("bool col → %s", typ)
|
||||
}
|
||||
if typ, _ := inferColumnType(col(mk("x", "")), 0); typ != "string" {
|
||||
t.Errorf("string col → %s", typ)
|
||||
}
|
||||
// digit-like value carrying text format (@) infers as string, not number —
|
||||
// this is what makes +table-put's string columns (ids/postcodes) survive read-back.
|
||||
if typ, _ := inferColumnType(col(mk(123.0, "@")), 0); typ != "string" {
|
||||
t.Errorf("@-format numeric-looking col → %s, want string", typ)
|
||||
}
|
||||
if typ, _ := inferColumnType([][]map[string]interface{}{}, 0); typ != "string" {
|
||||
t.Errorf("empty col → %s (want string)", typ)
|
||||
}
|
||||
|
||||
// Mixed number+text degrades to string (self-consistent: every value is then
|
||||
// a string), so the column round-trips and pandas doesn't choke. Numeric
|
||||
// coercion of the dirty cells is left to the caller (pandas to_numeric).
|
||||
if typ, _ := inferColumnType(col(mk(100.0, ""), mk("暂无", ""), mk(200.0, "")), 0); typ != "string" {
|
||||
t.Errorf("mixed number+text col → %s, want string", typ)
|
||||
}
|
||||
// A bare number mixed into a date column must NOT stay date (would serial-
|
||||
// convert the number into a bogus date) — degrades to string.
|
||||
if typ, _ := inferColumnType(col(mk(45306.0, "yyyy-mm-dd"), mk(5.0, "")), 0); typ != "string" {
|
||||
t.Errorf("date+bare-number col → %s, want string", typ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableGet_CellToTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
mk := func(v interface{}) map[string]interface{} { return map[string]interface{}{"value": v} }
|
||||
if v := cellToTyped(mk(45306.0), "date"); v != "2024-01-15" {
|
||||
t.Errorf("date serial → %v, want 2024-01-15", v)
|
||||
}
|
||||
if v := cellToTyped(mk(100.0), "number"); v != 100.0 {
|
||||
t.Errorf("number → %v", v)
|
||||
}
|
||||
if v := cellToTyped(mk(true), "bool"); v != true {
|
||||
t.Errorf("bool → %v", v)
|
||||
}
|
||||
if v := cellToTyped(mk(""), "string"); v != nil {
|
||||
t.Errorf("empty string → %v, want nil", v)
|
||||
}
|
||||
if v := cellToTyped(nil, "string"); v != nil {
|
||||
t.Errorf("nil → %v, want nil", v)
|
||||
}
|
||||
if v := cellToTyped(mk("hi"), "string"); v != "hi" {
|
||||
t.Errorf("string → %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTableGet_DigitStringRoundTrip: a column +table-put wrote as string (text
|
||||
// format @) reads back as string, not number — so leading-zero ids / postcodes
|
||||
// survive instead of collapsing to a number.
|
||||
func TestTableGet_DigitStringRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
region := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
|
||||
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+
|
||||
`[{"value":"邮编"}],`+
|
||||
`[{"value":"00123","cell_styles":{"number_format":"@"}}]`+
|
||||
`]}]}`)
|
||||
out, err := runShortcutWithStubs(t, TableGet,
|
||||
[]string{"--url", testURL, "--sheet-name", "S"}, region, cells)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
sheets, _ := data["sheets"].([]interface{})
|
||||
s0, _ := sheets[0].(map[string]interface{})
|
||||
cols, _ := s0["columns"].([]interface{})
|
||||
if c0, _ := cols[0].(map[string]interface{}); c0["type"] != "string" {
|
||||
t.Errorf("@-format col 邮编 → type %v, want string", c0["type"])
|
||||
}
|
||||
rows, _ := s0["rows"].([]interface{})
|
||||
if r0, _ := rows[0].([]interface{}); r0[0] != "00123" {
|
||||
t.Errorf("value = %v, want \"00123\" (leading zero preserved)", r0[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTableGet_ExecuteRoundTrip reads a sheet back and checks the output is the
|
||||
// same typed protocol +table-put consumes: date serial → ISO, number preserved,
|
||||
// types inferred from number_format.
|
||||
func TestTableGet_ExecuteRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
region := toolOutputStub(testToken, "read", `{"current_region":"A1:C2"}`)
|
||||
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+
|
||||
`[{"value":"门店"},{"value":"月份"},{"value":"销售额"}],`+
|
||||
`[{"value":"北京"},{"value":45306,"cell_styles":{"number_format":"yyyy-mm"}},{"value":259874,"cell_styles":{"number_format":"#,##0"}}]`+
|
||||
`]}]}`)
|
||||
out, err := runShortcutWithStubs(t, TableGet,
|
||||
[]string{"--url", testURL, "--sheet-name", "销售"}, region, cells)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
sheets, _ := data["sheets"].([]interface{})
|
||||
if len(sheets) != 1 {
|
||||
t.Fatalf("want 1 sheet, got %d", len(sheets))
|
||||
}
|
||||
s0, _ := sheets[0].(map[string]interface{})
|
||||
if s0["name"] != "销售" {
|
||||
t.Errorf("name = %v, want 销售", s0["name"])
|
||||
}
|
||||
cols, _ := s0["columns"].([]interface{})
|
||||
if len(cols) != 3 {
|
||||
t.Fatalf("want 3 columns, got %d", len(cols))
|
||||
}
|
||||
c1, _ := cols[1].(map[string]interface{})
|
||||
if c1["name"] != "月份" || c1["type"] != "date" || c1["format"] != "yyyy-mm" {
|
||||
t.Errorf("col 月份 = %#v, want name=月份 date yyyy-mm", c1)
|
||||
}
|
||||
c2, _ := cols[2].(map[string]interface{})
|
||||
if c2["type"] != "number" || c2["format"] != "#,##0" {
|
||||
t.Errorf("col 销售额 = %#v, want number #,##0", c2)
|
||||
}
|
||||
rows, _ := s0["rows"].([]interface{})
|
||||
r0, _ := rows[0].([]interface{})
|
||||
if r0[1] != "2024-01-15" {
|
||||
t.Errorf("date roundtrip = %v, want 2024-01-15 (serial 45306 → ISO)", r0[1])
|
||||
}
|
||||
if r0[2] != float64(259874) {
|
||||
t.Errorf("number = %v, want 259874", r0[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableGet_DryRunIncludesCellRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, TableGet, []string{"--url", testURL, "--sheet-name", "S"})
|
||||
found := false
|
||||
for _, c := range calls {
|
||||
body, _ := c.(map[string]interface{})["body"].(map[string]interface{})
|
||||
if body == nil {
|
||||
continue
|
||||
}
|
||||
if tn, _ := body["tool_name"].(string); tn == "get_cell_ranges" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("dry-run should include a get_cell_ranges read")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTableGet_AllSheets covers the "read every sheet" path (no --sheet-name):
|
||||
// get_workbook_structure lists sheets, then each is read in order.
|
||||
func TestTableGet_AllSheets(t *testing.T) {
|
||||
t.Parallel()
|
||||
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"s1","sheet_name":"A","index":0},{"sheet_id":"s2","sheet_name":"B","index":1}]}`)
|
||||
regionA := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
|
||||
cellsA := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"项"}],[{"value":"x"}]]}]}`)
|
||||
regionB := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
|
||||
cellsB := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"项"}],[{"value":"y"}]]}]}`)
|
||||
out, err := runShortcutWithStubs(t, TableGet,
|
||||
[]string{"--url", testURL}, structure, regionA, cellsA, regionB, cellsB)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
sheets, _ := data["sheets"].([]interface{})
|
||||
if len(sheets) != 2 {
|
||||
t.Fatalf("want 2 sheets (all), got %d", len(sheets))
|
||||
}
|
||||
got := []string{
|
||||
sheets[0].(map[string]interface{})["name"].(string),
|
||||
sheets[1].(map[string]interface{})["name"].(string),
|
||||
}
|
||||
if got[0] != "A" || got[1] != "B" {
|
||||
t.Errorf("sheet names = %v, want [A B] in workbook order", got)
|
||||
}
|
||||
}
|
||||
108
shortcuts/sheets/lark_sheet_undo.go
Normal file
108
shortcuts/sheets/lark_sheet_undo.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_undo ──────────────────────────────────────────────────
|
||||
//
|
||||
// Wraps:
|
||||
// - undo_last (write) — powers +undo
|
||||
//
|
||||
// Reverses the most recent edits this CLI link made to a spreadsheet, addressed
|
||||
// by document revision. Every write response carries `data.revision`; that
|
||||
// number is the undo anchor. The backend records an inverse changeset for every
|
||||
// write and indexes it by the revision it produced (see the undo design doc,
|
||||
// "方案 A · rev 寻址"); +undo asks the backend executor to locate that inverse
|
||||
// data through the revision pointer, verify nobody else changed the document
|
||||
// since (tip / continuity / object-version / identity checks), re-apply it in
|
||||
// reverse order on the node Workbook, and push the result upstream as a
|
||||
// collaboration change. The CLI only triggers the tool — the read-back endpoint
|
||||
// is space-internal and not reachable through the /open-apis gateway, so all
|
||||
// the heavy lifting stays server-side.
|
||||
//
|
||||
// +undo carries no sheet selector: undo is scoped to the spreadsheet + this
|
||||
// link's edit history, not a single sub-sheet. Selection:
|
||||
// - (no flags) : undo the latest edit, if it was made by this caller
|
||||
// - --rev N : undo anchored at revision N (from a prior write response);
|
||||
// rejected when the document has moved past N
|
||||
// - --steps N : undo the last N edits in one atomic call (default 1)
|
||||
|
||||
// Undo wraps undo_last: reverse the most recent edits made through this CLI
|
||||
// link, anchored by the revision a prior write returned (--rev), defaulting
|
||||
// to the latest edit.
|
||||
var Undo = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+undo",
|
||||
Description: "Undo the most recent edits this CLI link made to a spreadsheet (anchored by a write's returned revision).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+undo"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = undoInput(runtime, token)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := undoInput(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "undo_last", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := undoInput(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "undo_last", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Every write response carries data.revision — remember it; +undo --rev <that> undoes exactly that edit, and +recover --to-revision <that-1> is the full-rollback fallback.",
|
||||
"Without --rev, +undo targets the document's latest edit — it succeeds only when that edit was made through this CLI link by you.",
|
||||
"Repeated +undo steps back one edit at a time; --steps N undoes the last N edits in one atomic call. Already-undone edits are skipped automatically.",
|
||||
"If anyone else edited the document after (or between) the edits you want to undo, +undo refuses entirely and suggests +recover — it never partially undoes or overwrites others' changes.",
|
||||
"A success response with undone:0 plus warning_message means nothing was actually undone — the targeted revision wasn't produced by this caller, or was already undone.",
|
||||
"Use --dry-run to preview the request before running it.",
|
||||
},
|
||||
}
|
||||
|
||||
// undoInput builds the undo_last tool body. --rev anchors the undo at the
|
||||
// revision a prior write returned (omitted = latest); --steps selects how many
|
||||
// edits to reverse in one atomic call. Network-free; shared by Validate,
|
||||
// DryRun, and Execute.
|
||||
func undoInput(runtime flagView, token string) (map[string]interface{}, error) {
|
||||
input := map[string]interface{}{"excel_id": token}
|
||||
|
||||
if runtime.Changed("rev") {
|
||||
rev := runtime.Int("rev")
|
||||
if rev < 1 {
|
||||
return nil, common.FlagErrorf("--rev must be a positive revision number (from a prior write's data.revision)")
|
||||
}
|
||||
input["rev"] = rev
|
||||
}
|
||||
|
||||
steps := runtime.Int("steps")
|
||||
if steps < 1 {
|
||||
return nil, common.FlagErrorf("--steps must be >= 1")
|
||||
}
|
||||
input["steps"] = steps
|
||||
return input, nil
|
||||
}
|
||||
107
shortcuts/sheets/lark_sheet_undo_test.go
Normal file
107
shortcuts/sheets/lark_sheet_undo_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestUndo_DryRun asserts the undo_last body for the three selection shapes:
|
||||
// default (latest, steps=1), explicit --steps, and a --rev anchor. Numbers
|
||||
// round-trip through the wire JSON as float64, matching the other dry-run
|
||||
// body tests.
|
||||
func TestUndo_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "default undoes the latest edit",
|
||||
args: []string{"--url", testURL},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"steps": float64(1),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit --steps",
|
||||
args: []string{"--url", testURL, "--steps", "3"},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"steps": float64(3),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "--rev anchors at a write's returned revision",
|
||||
args: []string{"--spreadsheet-token", testToken, "--rev", "123"},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"rev": float64(123),
|
||||
"steps": float64(1),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "--rev composes with --steps",
|
||||
args: []string{"--url", testURL, "--rev", "123", "--steps", "2"},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"rev": float64(123),
|
||||
"steps": float64(2),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, Undo, tt.args)
|
||||
got := decodeToolInput(t, body, "undo_last")
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUndo_Validation covers the XOR token check, the --rev lower bound, and
|
||||
// the --steps lower bound.
|
||||
func TestUndo_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "needs --url or --spreadsheet-token",
|
||||
args: []string{},
|
||||
wantMsg: "at least one of --url or --spreadsheet-token",
|
||||
},
|
||||
{
|
||||
name: "--rev must be positive",
|
||||
args: []string{"--url", testURL, "--rev", "0"},
|
||||
wantMsg: "--rev must be a positive revision number",
|
||||
},
|
||||
{
|
||||
name: "--steps must be >= 1",
|
||||
args: []string{"--url", testURL, "--steps", "0"},
|
||||
wantMsg: "--steps must be >= 1",
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, Undo, append(tt.args, "--dry-run"))
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; got nil. stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, tt.wantMsg) {
|
||||
t.Errorf("error message missing %q; got=%s", tt.wantMsg, combined)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,14 @@ package sheets
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/drive"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_workbook ──────────────────────────────────────────────
|
||||
@@ -540,6 +535,18 @@ var SheetSetTabColor = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// SheetShowGridline / SheetHideGridline toggle a sub-sheet's gridline display.
|
||||
// Gridline show/hide is the same two-state-via-operation shape as
|
||||
// +sheet-hide/+sheet-unhide (no --visible flag), so they reuse
|
||||
// newSheetVisibilityShortcut; only the operation enum differs.
|
||||
var SheetShowGridline = newSheetVisibilityShortcut(
|
||||
"+sheet-show-gridline", "Show gridlines on a sub-sheet.", "show_gridline",
|
||||
)
|
||||
|
||||
var SheetHideGridline = newSheetVisibilityShortcut(
|
||||
"+sheet-hide-gridline", "Hide gridlines on a sub-sheet.", "hide_gridline",
|
||||
)
|
||||
|
||||
// ─── +workbook-create (legacy OAPI, cli_status: cli-only) ────────────
|
||||
//
|
||||
// Creates a brand-new spreadsheet via POST /sheets/v3/spreadsheets, then
|
||||
@@ -553,7 +560,7 @@ var SheetSetTabColor = common.Shortcut{
|
||||
var WorkbookCreate = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+workbook-create",
|
||||
Description: "Create a new spreadsheet (optionally pre-filled with --headers and --values).",
|
||||
Description: "Create a new spreadsheet, optionally pre-filled with untyped --headers/--values or typed --sheets (type-faithful one-step create + write).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -563,6 +570,20 @@ var WorkbookCreate = common.Shortcut{
|
||||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||||
return common.FlagErrorf("--title is required")
|
||||
}
|
||||
// --sheets (typed) is an alternative, mutually exclusive data entry to the
|
||||
// untyped --headers/--values. Gated on Changed (not just non-empty): an
|
||||
// explicitly-given but empty --sheets (e.g. empty stdin / file) is an
|
||||
// error, not a silent fall-through to creating an empty workbook.
|
||||
if runtime.Changed("sheets") {
|
||||
if strings.TrimSpace(runtime.Str("sheets")) == "" {
|
||||
return common.FlagErrorf("--sheets was given but resolved to empty (empty stdin/file?); pass a typed payload, or drop --sheets to create an empty workbook")
|
||||
}
|
||||
if runtime.Str("headers") != "" || runtime.Str("values") != "" {
|
||||
return common.FlagErrorf("--sheets is mutually exclusive with --headers/--values")
|
||||
}
|
||||
_, err := parseTablePutPayload(runtime)
|
||||
return err
|
||||
}
|
||||
if runtime.Str("headers") != "" {
|
||||
v, err := parseJSONFlag(runtime, "headers")
|
||||
if err != nil {
|
||||
@@ -598,10 +619,33 @@ var WorkbookCreate = common.Shortcut{
|
||||
POST("/open-apis/sheets/v3/spreadsheets").
|
||||
Desc("create spreadsheet").
|
||||
Body(body)
|
||||
// Typed --sheets path: preview the create POST, then one set_cell_range
|
||||
// write per sheet (the first adopts the new workbook's default sheet).
|
||||
// Mirrors +table-put's dry-run, against a placeholder token.
|
||||
if runtime.Changed("sheets") {
|
||||
if payload, err := parseTablePutPayload(runtime); err == nil {
|
||||
headerStyle := runtime.Bool("header-style")
|
||||
for i := range payload.Sheets {
|
||||
s := &payload.Sheets[i]
|
||||
matrix, _ := buildSheetMatrix(s, headerStyle, headerOn(s))
|
||||
input := map[string]interface{}{
|
||||
"excel_id": "<new-token>",
|
||||
"sheet_name": s.Name,
|
||||
"range": tablePutFullRange(s, len(matrix)),
|
||||
"cells": matrix,
|
||||
}
|
||||
wireBody, _ := buildToolBody(ToolKindWrite, "set_cell_range", input)
|
||||
dry.POST("/open-apis/sheet_ai/v2/spreadsheets/<new-token>/tools/invoke_write").
|
||||
Desc(fmt.Sprintf("write typed sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), len(s.Columns))).
|
||||
Body(wireBody)
|
||||
}
|
||||
}
|
||||
return dry
|
||||
}
|
||||
if fill, _ := buildInitialFillInput(runtime); fill != nil {
|
||||
fill["excel_id"] = "<new-token>"
|
||||
fill["sheet_id"] = "<first-sheet-id>" // resolved from the workbook at execute time
|
||||
wireBody, _ := buildToolBody("set_cell_range", fill)
|
||||
wireBody, _ := buildToolBody(ToolKindWrite, "set_cell_range", fill)
|
||||
dry.POST("/open-apis/sheet_ai/v2/spreadsheets/<new-token>/tools/invoke_write").
|
||||
Desc("fill headers + data via set_cell_range (sheet_id resolved after create)").
|
||||
Body(wireBody)
|
||||
@@ -628,6 +672,30 @@ var WorkbookCreate = common.Shortcut{
|
||||
|
||||
result := map[string]interface{}{"spreadsheet": ss}
|
||||
|
||||
// Typed --sheets path: write type-faithful data into the brand-new
|
||||
// workbook, adopting its default sheet as the first payload sheet so no
|
||||
// empty "Sheet1" is left behind. Mutually exclusive with --headers/--values
|
||||
// (enforced in Validate).
|
||||
if runtime.Changed("sheets") {
|
||||
payload, err := parseTablePutPayload(runtime)
|
||||
if err != nil {
|
||||
return err // already validated; defensive
|
||||
}
|
||||
firstSheetID, err := lookupFirstSheetID(ctx, runtime, token)
|
||||
if err != nil {
|
||||
return workbookCreatedButFillFailed(token, ss,
|
||||
fmt.Sprintf("resolving its default sheet for the typed write failed: %v", err))
|
||||
}
|
||||
written, err := writeTypedSheets(ctx, runtime, token, payload, runtime.Bool("header-style"), firstSheetID)
|
||||
if err != nil {
|
||||
return workbookCreatedButFillFailed(token, ss,
|
||||
fmt.Sprintf("typed write failed: %v", err))
|
||||
}
|
||||
result["sheets"] = written
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --headers / --values are optional. buildInitialFillInput returns
|
||||
// (nil, nil) when both are absent or empty, in which case we skip the
|
||||
// fill entirely rather than dereferencing a nil map.
|
||||
@@ -657,6 +725,7 @@ var WorkbookCreate = common.Shortcut{
|
||||
},
|
||||
Tips: []string{
|
||||
"--headers and --values are optional follow-up writes. They use the same set_cell_range tool as +cells-set; partial failure leaves the spreadsheet created but empty.",
|
||||
"--sheets writes typed, type-faithful data (dates → real dates, numbers keep precision) in one step — the create + typed write that +table-put can't do on its own. Mutually exclusive with --headers/--values; the new workbook's default sheet becomes the first typed sheet (no empty Sheet1 left behind).",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -770,178 +839,62 @@ var WorkbookExport = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
ext := runtime.Str("file-extension")
|
||||
if ext == "" {
|
||||
ext = "xlsx"
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"token": token,
|
||||
"type": "sheet",
|
||||
"file_extension": ext,
|
||||
}
|
||||
if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" {
|
||||
body["sub_id"] = sid
|
||||
}
|
||||
dry := common.NewDryRunAPI().
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Desc("create export task").
|
||||
Body(body).
|
||||
GET("/open-apis/drive/v1/export_tasks/<ticket>").
|
||||
Desc("poll task status").
|
||||
Params(map[string]interface{}{"token": token})
|
||||
if strings.TrimSpace(runtime.Str("output-path")) != "" {
|
||||
dry.GET("/open-apis/drive/v1/export_tasks/file/<file_token>/download").
|
||||
Desc("download exported file")
|
||||
}
|
||||
return dry
|
||||
p, _ := workbookExportParams(runtime)
|
||||
p.OutputDir = strings.TrimSpace(runtime.Str("output-path"))
|
||||
return drive.PlanExportDryRun(runtime, p)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
p, err := workbookExportParams(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ext := runtime.Str("file-extension")
|
||||
if ext == "" {
|
||||
ext = "xlsx"
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"token": token,
|
||||
"type": "sheet",
|
||||
"file_extension": ext,
|
||||
}
|
||||
if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" {
|
||||
body["sub_id"] = sid
|
||||
}
|
||||
taskData, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ticket := common.GetString(taskData, "ticket")
|
||||
if ticket == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "export task created but ticket missing")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"file_extension": ext,
|
||||
}
|
||||
|
||||
// Poll up to ~30s for completion.
|
||||
var fileToken, fileName string
|
||||
for attempt := 0; attempt < 15; attempt++ {
|
||||
status, err := pollExportTask(runtime, token, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch status.JobStatus {
|
||||
case 0: // success
|
||||
fileToken = status.FileToken
|
||||
fileName = status.FileName
|
||||
result["file_token"] = fileToken
|
||||
result["file_name"] = fileName
|
||||
result["file_size"] = status.FileSize
|
||||
attempt = 999 // break outer loop
|
||||
case 1, 2: // pending / in progress
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
default: // any non-zero status outside the in-progress window is a failure
|
||||
if status.JobErrorMsg != "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "export task %s failed: %s", ticket, status.JobErrorMsg)
|
||||
}
|
||||
return output.Errorf(output.ExitAPI, "api_error", "export task %s failed with job_status=%d", ticket, status.JobStatus)
|
||||
}
|
||||
}
|
||||
if fileToken == "" {
|
||||
result["status"] = "polling_timeout"
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
outPath := strings.TrimSpace(runtime.Str("output-path"))
|
||||
if outPath == "" {
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
saved, err := downloadExportFile(ctx, runtime, fileToken, outPath, fileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result["saved_path"] = saved
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
applyWorkbookOutputPath(&p, runtime.FileIO(), runtime.Str("output-path"))
|
||||
return drive.RunExport(ctx, runtime, p)
|
||||
},
|
||||
Tips: []string{
|
||||
"Polls up to ~30s (15 × 2s). For very large workbooks rerun and pass --output-path to capture the file once status flips to success.",
|
||||
"Polls for a bounded window; if the export is still running it returns a resume reference instead of blocking. Pass --output-path to download the file once ready (omit it to only create the export task and get the file token back).",
|
||||
},
|
||||
}
|
||||
|
||||
type exportTaskStatus struct {
|
||||
JobStatus int
|
||||
JobErrorMsg string
|
||||
FileToken string
|
||||
FileName string
|
||||
FileSize int64
|
||||
FileExtension string
|
||||
}
|
||||
|
||||
func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (exportTaskStatus, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)),
|
||||
map[string]interface{}{"token": token},
|
||||
nil,
|
||||
)
|
||||
// workbookExportParams builds the shared drive export request for
|
||||
// +workbook-export: spreadsheet token + sheet locator, pinned to type=sheet.
|
||||
// workbook-export has always overwritten the target, so Overwrite is set. The
|
||||
// --output-path → OutputDir/FileName split (which needs a Stat) is applied
|
||||
// separately by applyWorkbookOutputPath so Validate/DryRun stay I/O-free.
|
||||
func workbookExportParams(runtime *common.RuntimeContext) (drive.ExportParams, error) {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return exportTaskStatus{}, err
|
||||
return drive.ExportParams{}, err
|
||||
}
|
||||
result := common.GetMap(data, "result")
|
||||
if result == nil {
|
||||
return exportTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "export task %s: empty result", ticket)
|
||||
ext := runtime.Str("file-extension")
|
||||
if ext == "" {
|
||||
ext = "xlsx"
|
||||
}
|
||||
js, _ := util.ToFloat64(result["job_status"])
|
||||
fs, _ := util.ToFloat64(result["file_size"])
|
||||
return exportTaskStatus{
|
||||
JobStatus: int(js),
|
||||
JobErrorMsg: common.GetString(result, "job_error_msg"),
|
||||
FileToken: common.GetString(result, "file_token"),
|
||||
FileName: common.GetString(result, "file_name"),
|
||||
FileSize: int64(fs),
|
||||
FileExtension: common.GetString(result, "file_extension"),
|
||||
return drive.ExportParams{
|
||||
Token: token,
|
||||
DocType: "sheet",
|
||||
FileExtension: ext,
|
||||
SubID: strings.TrimSpace(runtime.Str("sheet-id")),
|
||||
Overwrite: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outPath, preferredName string) (string, error) {
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
}, larkcore.WithFileDownload())
|
||||
if err != nil {
|
||||
return "", output.ErrNetwork("download failed: %s", err)
|
||||
// applyWorkbookOutputPath maps the single --output-path flag onto the drive
|
||||
// export OutputDir/FileName pair, preserving the legacy behavior: empty = no
|
||||
// download (return the ready file token only); an existing directory = download
|
||||
// into it under the server-provided name; otherwise treat it as a file path and
|
||||
// split into dir + base name.
|
||||
func applyWorkbookOutputPath(p *drive.ExportParams, fio fileio.FileIO, outputPath string) {
|
||||
outputPath = strings.TrimSpace(outputPath)
|
||||
if outputPath == "" {
|
||||
return
|
||||
}
|
||||
if apiResp.StatusCode >= 400 {
|
||||
return "", output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody))
|
||||
if info, err := fio.Stat(outputPath); err == nil && info.IsDir() {
|
||||
p.OutputDir = outputPath
|
||||
return
|
||||
}
|
||||
target := outPath
|
||||
if info, statErr := runtime.FileIO().Stat(outPath); statErr == nil && info.IsDir() {
|
||||
name := strings.TrimSpace(preferredName)
|
||||
if name == "" {
|
||||
name = client.ResolveFilename(apiResp)
|
||||
}
|
||||
target = filepath.Join(outPath, name)
|
||||
}
|
||||
if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{
|
||||
ContentType: apiResp.Header.Get("Content-Type"),
|
||||
ContentLength: int64(len(apiResp.RawBody)),
|
||||
}, strings.NewReader(string(apiResp.RawBody))); err != nil {
|
||||
return "", common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
resolved, _ := runtime.FileIO().ResolvePath(target)
|
||||
if resolved == "" {
|
||||
resolved = target
|
||||
}
|
||||
return resolved, nil
|
||||
p.OutputDir = filepath.Dir(outputPath)
|
||||
p.FileName = filepath.Base(outputPath)
|
||||
}
|
||||
|
||||
// lookupSheetIndex finds a sub-sheet by id or name and returns its canonical
|
||||
@@ -1033,3 +986,45 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok
|
||||
}
|
||||
return bestID, nil
|
||||
}
|
||||
|
||||
// ─── +workbook-import (reuses drive import core, cli_status: cli-only) ──
|
||||
//
|
||||
// Imports a local xlsx/xls/csv file as a brand-new spreadsheet. The full
|
||||
// upload → create-task → poll flow is the shared drive import core
|
||||
// (drive.RunImport); this shortcut only pins the target type to "sheet" and
|
||||
// omits the bitable-only --target-token. Symmetric with +workbook-export.
|
||||
// Not exposed as an MCP tool.
|
||||
|
||||
// WorkbookImport imports a local spreadsheet file as a new Feishu spreadsheet
|
||||
// by delegating to the shared drive import core with type fixed to "sheet".
|
||||
var WorkbookImport = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+workbook-import",
|
||||
Description: "Import a local xlsx/xls/csv file as a new spreadsheet (async + poll). Reuses the drive import core with type fixed to sheet.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:document.media:upload", "docs:document:import"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+workbook-import"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return drive.ValidateImport(workbookImportParams(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return drive.PlanImportDryRun(runtime, workbookImportParams(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return drive.RunImport(ctx, runtime, workbookImportParams(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
// workbookImportParams builds the drive import request for +workbook-import,
|
||||
// pinning DocType to "sheet". The bitable-only --target-token is intentionally
|
||||
// not exposed here — use drive +import for non-sheet import targets.
|
||||
func workbookImportParams(runtime *common.RuntimeContext) drive.ImportParams {
|
||||
return drive.ImportParams{
|
||||
File: runtime.Str("file"),
|
||||
DocType: "sheet",
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
}
|
||||
}
|
||||
|
||||
72
shortcuts/sheets/lark_sheet_workbook_export_test.go
Normal file
72
shortcuts/sheets/lark_sheet_workbook_export_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestWorkbookExport_ExecuteExportOnly covers the no-download path: without
|
||||
// --output-path, +workbook-export delegates to the shared drive export core
|
||||
// with OutputDir="" so it creates + polls the export task and returns the ready
|
||||
// file token without writing a local file (downloaded=false).
|
||||
func TestWorkbookExport_ExecuteExportOnly(t *testing.T) {
|
||||
stubs := []*httpmock.Stub{
|
||||
{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"ticket": "tk_export"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_export",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"result": map[string]interface{}{
|
||||
"job_status": float64(0),
|
||||
"file_token": "ftk_xlsx",
|
||||
"file_name": "report.xlsx",
|
||||
"file_size": float64(2048),
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := runShortcutWithStubs(t, WorkbookExport, []string{
|
||||
"--url", testURL, "--file-extension", "xlsx", "--as", "user",
|
||||
}, stubs...)
|
||||
if err != nil {
|
||||
t.Fatalf("export-only execute failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
idx := strings.Index(out, "{")
|
||||
if idx < 0 {
|
||||
t.Fatalf("no JSON envelope:\n%s", out)
|
||||
}
|
||||
var env struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
|
||||
}
|
||||
if env.Data["ready"] != true {
|
||||
t.Errorf("ready = %v, want true", env.Data["ready"])
|
||||
}
|
||||
if env.Data["downloaded"] != false {
|
||||
t.Errorf("downloaded = %v, want false (no --output-path)", env.Data["downloaded"])
|
||||
}
|
||||
if env.Data["file_token"] != "ftk_xlsx" {
|
||||
t.Errorf("file_token = %v, want ftk_xlsx", env.Data["file_token"])
|
||||
}
|
||||
if env.Data["doc_type"] != "sheet" {
|
||||
t.Errorf("doc_type = %v, want sheet", env.Data["doc_type"])
|
||||
}
|
||||
}
|
||||
135
shortcuts/sheets/lark_sheet_workbook_import_test.go
Normal file
135
shortcuts/sheets/lark_sheet_workbook_import_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
// chdirTemp switches into a fresh temp dir for the duration of the test and
|
||||
// restores the original cwd afterwards. +workbook-import is the first sheets
|
||||
// shortcut that stat()s a real local file, so these tests need a working dir.
|
||||
func chdirTemp(t *testing.T) {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(t.TempDir()); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(orig) })
|
||||
}
|
||||
|
||||
// TestWorkbookImport_DryRunPinsSheetType verifies the shortcut delegates to the
|
||||
// shared drive import core and hard-codes the import target type to "sheet".
|
||||
func TestWorkbookImport_DryRunPinsSheetType(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
calls := parseDryRunAPI(t, WorkbookImport, []string{"--file", "./data.xlsx"})
|
||||
|
||||
var createBody map[string]interface{}
|
||||
for _, c := range calls {
|
||||
cm, _ := c.(map[string]interface{})
|
||||
if u, _ := cm["url"].(string); u == "/open-apis/drive/v1/import_tasks" {
|
||||
createBody, _ = cm["body"].(map[string]interface{})
|
||||
}
|
||||
}
|
||||
if createBody == nil {
|
||||
t.Fatalf("no import_tasks create call in dry-run: %#v", calls)
|
||||
}
|
||||
if createBody["type"] != "sheet" {
|
||||
t.Errorf("import type = %v, want sheet (must be pinned regardless of file)", createBody["type"])
|
||||
}
|
||||
if createBody["file_extension"] != "xlsx" {
|
||||
t.Errorf("file_extension = %v, want xlsx", createBody["file_extension"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookImport_RejectsNonSheetFile ensures a file that cannot become a
|
||||
// spreadsheet (e.g. .docx) is rejected up front by the pinned-sheet validation.
|
||||
func TestWorkbookImport_RejectsNonSheetFile(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("notes.docx", []byte("fake-docx"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
// Validate runs before DryRun, so the pinned-sheet check rejects .docx up
|
||||
// front and the error surfaces through the normal envelope/err path.
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookImport, []string{"--file", "./notes.docx", "--dry-run"})
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "can only be imported") {
|
||||
t.Errorf("expected .docx → sheet type-mismatch rejection; got stdout=%s stderr=%s err=%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookImport_ExecuteCreatesSheet runs the full upload → create → poll
|
||||
// flow against stubs and asserts the resulting URL is a /sheets/ link.
|
||||
func TestWorkbookImport_ExecuteCreatesSheet(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
stubs := []*httpmock.Stub{
|
||||
{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"file_token": "file_import_media"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/import_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"ticket": "tk_sheet"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/tk_sheet",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"result": map[string]interface{}{
|
||||
"token": "shtcn_imported",
|
||||
"type": "sheet",
|
||||
"job_status": float64(0),
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := runShortcutWithStubs(t, WorkbookImport, []string{"--file", "./data.csv", "--as", "user"}, stubs...)
|
||||
if err != nil {
|
||||
t.Fatalf("import execute failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
idx := strings.Index(out, "{")
|
||||
if idx < 0 {
|
||||
t.Fatalf("execute output has no JSON envelope:\n%s", out)
|
||||
}
|
||||
var env struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
|
||||
}
|
||||
if url, _ := env.Data["url"].(string); !strings.Contains(url, "/sheets/") {
|
||||
t.Errorf("imported url = %q, want a /sheets/ link", url)
|
||||
}
|
||||
if tok, _ := env.Data["token"].(string); tok != "shtcn_imported" {
|
||||
t.Errorf("token = %q, want shtcn_imported", tok)
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,28 @@ func TestWorkbookShortcuts_DryRun(t *testing.T) {
|
||||
"tab_color": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-show-gridline",
|
||||
sc: SheetShowGridline,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "show_gridline",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-hide-gridline",
|
||||
sc: SheetHideGridline,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "hide_gridline",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -339,21 +361,21 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookExport_DryRun checks the 2-or-3 step plan depending on
|
||||
// --output-path. The order should be: POST → GET (poll) → optional GET
|
||||
// (download).
|
||||
// TestWorkbookExport_DryRun verifies the export dry-run now delegates to the
|
||||
// shared drive export core: a single create-task POST (poll + download are
|
||||
// described inline rather than as separate api entries).
|
||||
func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) {
|
||||
t.Run("xlsx create-task body pins type=sheet", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls))
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1 (create export task)", len(calls))
|
||||
}
|
||||
create := calls[0].(map[string]interface{})
|
||||
if create["url"] != "/open-apis/drive/v1/export_tasks" {
|
||||
t.Errorf("first url = %v", create["url"])
|
||||
t.Errorf("url = %v", create["url"])
|
||||
}
|
||||
body, _ := create["body"].(map[string]interface{})
|
||||
if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken {
|
||||
@@ -361,22 +383,18 @@ func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csv → 3 steps, with sub_id", func(t *testing.T) {
|
||||
t.Run("csv includes sub_id from --sheet-id", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookExport, []string{
|
||||
"--url", testURL, "--file-extension", "csv", "--sheet-id", "sh1",
|
||||
"--output-path", "/tmp/out.csv",
|
||||
})
|
||||
if len(calls) != 3 {
|
||||
t.Fatalf("api calls = %d, want 3", len(calls))
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1", len(calls))
|
||||
}
|
||||
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
|
||||
if body["sub_id"] != "sh1" {
|
||||
t.Errorf("csv export missing sub_id: %#v", body)
|
||||
}
|
||||
dl := calls[2].(map[string]interface{})
|
||||
if !strings.Contains(dl["url"].(string), "/export_tasks/file/") {
|
||||
t.Errorf("download url = %v", dl["url"])
|
||||
if body["type"] != "sheet" || body["sub_id"] != "sh1" {
|
||||
t.Errorf("csv export body = %#v (want type=sheet, sub_id=sh1)", body)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -727,7 +727,7 @@ var CellsSetImage = common.Shortcut{
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(imgPath)
|
||||
}
|
||||
setCellBody, _ := buildToolBody("set_cell_range", map[string]interface{}{
|
||||
setCellBody, _ := buildToolBody(ToolKindWrite, "set_cell_range", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": strings.TrimSpace(runtime.Str("range")),
|
||||
"sheet_id": sheetSelectorPlaceholder(sheetID, sheetName),
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
@@ -14,6 +16,26 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// sheetTxnIDEnv is the env var carrying a caller-provided, session-stable
|
||||
// transaction id for sheet tool calls.
|
||||
const sheetTxnIDEnv = "LARK_CLI_SHEET_TRANSACTION_ID"
|
||||
|
||||
// sheetTransactionID returns the optional per-session transaction id from the
|
||||
// environment, or "" when unset.
|
||||
//
|
||||
// NOTE: +undo does NOT use this id to locate edits — the server addresses undo
|
||||
// by document revision (the `rev` a write returns; see +undo --rev), not by
|
||||
// transaction id. This env var's only purpose is optional concurrency
|
||||
// isolation: write tools persist their reverse ("undo") changeset keyed by the
|
||||
// request's transaction id, and the server mints a fresh uuid per request when
|
||||
// none is supplied, so each invocation lands in its own undo stack by default.
|
||||
// Set a stable id across commands only to deliberately share one isolated undo
|
||||
// stack across a group of edits; empty preserves the per-request default and is
|
||||
// the norm.
|
||||
func sheetTransactionID() string {
|
||||
return strings.TrimSpace(os.Getenv(sheetTxnIDEnv))
|
||||
}
|
||||
|
||||
// ToolKind selects the One-OpenAPI endpoint and its rate-limit bucket.
|
||||
//
|
||||
// - ToolKindRead → POST .../tools/invoke_read (scope sheets:spreadsheet:read, 10 qps)
|
||||
@@ -39,15 +61,27 @@ func toolInvokePath(token string, kind ToolKind) string {
|
||||
// buildToolBody constructs the One-OpenAPI request body for a tool invocation.
|
||||
// `input` is serialized to a JSON string per the API contract; callers pass
|
||||
// a typed Go map and never need to handle JSON encoding themselves.
|
||||
func buildToolBody(toolName string, input map[string]interface{}) (map[string]interface{}, error) {
|
||||
func buildToolBody(kind ToolKind, toolName string, input map[string]interface{}) (map[string]interface{}, error) {
|
||||
inputJSON, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode tool input: %w", err)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
body := map[string]interface{}{
|
||||
"tool_name": toolName,
|
||||
"input": string(inputJSON),
|
||||
}, nil
|
||||
}
|
||||
// Thread a session-stable transaction id (when provided) so a group of
|
||||
// edits and a later +undo share one undo stack. Omitted when unset, leaving
|
||||
// the server to mint a per-request id as before. Only write tools join the
|
||||
// undo transaction; reads must never carry it — a read scoped to a
|
||||
// transaction id resolves against that transaction's (often empty) snapshot
|
||||
// instead of the live document, so it would read back blank.
|
||||
if kind == ToolKindWrite {
|
||||
if txID := sheetTransactionID(); txID != "" {
|
||||
body["extra"] = map[string]interface{}{"transaction_id": txID}
|
||||
}
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// callTool invokes a sheet-ai tool via the One-OpenAPI endpoint and decodes
|
||||
@@ -65,7 +99,7 @@ func callTool(
|
||||
toolName string,
|
||||
input map[string]interface{},
|
||||
) (interface{}, error) {
|
||||
body, err := buildToolBody(toolName, input)
|
||||
body, err := buildToolBody(kind, toolName, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -109,7 +143,7 @@ func invokeToolDryRun(
|
||||
toolName string,
|
||||
input map[string]interface{},
|
||||
) *common.DryRunAPI {
|
||||
wireBody, _ := buildToolBody(toolName, input)
|
||||
wireBody, _ := buildToolBody(kind, toolName, input)
|
||||
return common.NewDryRunAPI().
|
||||
POST(toolInvokePath(token, kind)).
|
||||
Body(wireBody).
|
||||
|
||||
57
shortcuts/sheets/sheet_ai_api_test.go
Normal file
57
shortcuts/sheets/sheet_ai_api_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import "testing"
|
||||
|
||||
// cellsSetArgs is a minimal valid +cells-set invocation used to inspect the
|
||||
// tool-call request body.
|
||||
func cellsSetArgs() []string {
|
||||
return []string{
|
||||
"--spreadsheet-token", testToken,
|
||||
"--sheet-id", testSheetID,
|
||||
"--range", "A1",
|
||||
"--cells", `[[{"value":"x"}]]`,
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildToolBody_ThreadsTransactionID verifies that a session-stable
|
||||
// transaction id from the environment is threaded into the request body's
|
||||
// extra.transaction_id, so a group of edits and a later +undo share one undo
|
||||
// stack.
|
||||
func TestBuildToolBody_ThreadsTransactionID(t *testing.T) {
|
||||
t.Setenv(sheetTxnIDEnv, "tx_test_123")
|
||||
body := parseDryRunBody(t, CellsSet, cellsSetArgs())
|
||||
extra, ok := body["extra"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("extra missing from body: %#v", body)
|
||||
}
|
||||
if extra["transaction_id"] != "tx_test_123" {
|
||||
t.Errorf("transaction_id = %#v, want tx_test_123", extra["transaction_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildToolBody_OmitsTransactionIDWhenUnset verifies the body carries no
|
||||
// extra when the env var is empty, preserving the per-request default.
|
||||
func TestBuildToolBody_OmitsTransactionIDWhenUnset(t *testing.T) {
|
||||
t.Setenv(sheetTxnIDEnv, "")
|
||||
body := parseDryRunBody(t, CellsSet, cellsSetArgs())
|
||||
if _, ok := body["extra"]; ok {
|
||||
t.Errorf("extra should be absent when %s is unset: %#v", sheetTxnIDEnv, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildToolBody_OmitsTransactionIDForReads verifies that read tools never
|
||||
// carry a transaction id even when one is set: a read scoped to a transaction
|
||||
// resolves against that transaction's snapshot (often empty) instead of the
|
||||
// live document, so threading it would make reads return blank cells.
|
||||
func TestBuildToolBody_OmitsTransactionIDForReads(t *testing.T) {
|
||||
t.Setenv(sheetTxnIDEnv, "tx_test_123")
|
||||
body := parseDryRunBody(t, CellsGet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--range", "A1",
|
||||
})
|
||||
if _, ok := body["extra"]; ok {
|
||||
t.Errorf("read tool must not carry extra.transaction_id: %#v", body)
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,11 @@ func shortcutList() []common.Shortcut {
|
||||
SheetHide,
|
||||
SheetUnhide,
|
||||
SheetSetTabColor,
|
||||
SheetShowGridline,
|
||||
SheetHideGridline,
|
||||
WorkbookCreate,
|
||||
WorkbookExport,
|
||||
WorkbookImport,
|
||||
|
||||
// lark_sheet_sheet_structure
|
||||
SheetInfo,
|
||||
@@ -56,6 +59,10 @@ func shortcutList() []common.Shortcut {
|
||||
CellsGet,
|
||||
CsvGet,
|
||||
DropdownGet,
|
||||
TableGet,
|
||||
|
||||
// lark_sheet_undo
|
||||
Undo,
|
||||
|
||||
// lark_sheet_search_replace
|
||||
CellsSearch,
|
||||
@@ -67,6 +74,7 @@ func shortcutList() []common.Shortcut {
|
||||
CellsSetImage,
|
||||
CsvPut,
|
||||
DropdownSet,
|
||||
TablePut,
|
||||
|
||||
// lark_sheet_range_operations
|
||||
CellsClear,
|
||||
|
||||
@@ -28,8 +28,6 @@ metadata:
|
||||
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`。
|
||||
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
|
||||
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
|
||||
@@ -268,8 +266,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+preview`](references/lark-drive-preview.md) | List or download available preview artifacts for a Drive file; explicit `--type` required for downloads |
|
||||
| [`+cover`](references/lark-drive-cover.md) | List or download stable built-in cover presets for a Drive file; download-time HTTP 404 means the file has no artifact for that cover spec |
|
||||
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by exact SHA-256 hash by default, or use `--quick` for a best-effort modified-time diff that skips remote downloads; reports `new_local` / `new_remote` / `modified` / `unchanged` plus `detection=exact` or `detection=quick`. Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
|
||||
| [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. |
|
||||
| `+sync` | Two-way local ↔ Drive sync. Reuses `+status` diff buckets, pulls `new_remote`, pushes `new_local`, and resolves `modified` via `--on-conflict=remote-wins|local-wins|keep-both|ask`. `--quick` enables best-effort modified-time diffing (timestamp mismatches can still trigger real pull/push actions), `--on-duplicate-remote` supports `fail|newest|oldest`, and the command is intentionally non-destructive (no delete on either side). |
|
||||
@@ -351,40 +347,31 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
|
||||
- `update_reaction` — 添加/删除 reaction
|
||||
|
||||
### quota_details
|
||||
|
||||
- `get` — 获取当前用户的容量信息,包含各业务使用量、租户配额是否超限、用户配额、所在部门配额
|
||||
- 仅支持 `--as user`,不要使用默认的 bot 身份
|
||||
- `quota_detail_id` 传当前用户的 `user_id`
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------------------------------------------------|--------------------------------------|
|
||||
| `files.copy` | `docs:document:copy` |
|
||||
| `files.create_folder` | `space:folder:create` |
|
||||
| `files.list` | `space:document:retrieve` |
|
||||
| `files.patch` | `docx:document:write_only` |
|
||||
| `file.comments.batch_query` | `docs:document.comment:read` |
|
||||
| `file.comments.create_v2` | `docs:document.comment:create` |
|
||||
| `file.comments.list` | `docs:document.comment:read` |
|
||||
| `file.comments.patch` | `docs:document.comment:update` |
|
||||
| `file.comment.replys.create` | `docs:document.comment:create` |
|
||||
| `file.comment.replys.delete` | `docs:document.comment:delete` |
|
||||
| `file.comment.replys.list` | `docs:document.comment:read` |
|
||||
| `file.comment.replys.update` | `docs:document.comment:update` |
|
||||
| `permission.members.auth` | `docs:permission.member:auth` |
|
||||
| `permission.members.create` | `docs:permission.member:create` |
|
||||
| `permission.members.transfer_owner` | `docs:permission.member:transfer` |
|
||||
| `permission.public.get` | `docs:permission.setting:read` |
|
||||
| 方法 | 所需 scope |
|
||||
|------------------------------------------------|-----------------------------------|
|
||||
| `files.copy` | `docs:document:copy` |
|
||||
| `files.create_folder` | `space:folder:create` |
|
||||
| `files.list` | `space:document:retrieve` |
|
||||
| `files.patch` | `docx:document:write_only` |
|
||||
| `file.comments.batch_query` | `docs:document.comment:read` |
|
||||
| `file.comments.create_v2` | `docs:document.comment:create` |
|
||||
| `file.comments.list` | `docs:document.comment:read` |
|
||||
| `file.comments.patch` | `docs:document.comment:update` |
|
||||
| `file.comment.replys.create` | `docs:document.comment:create` |
|
||||
| `file.comment.replys.delete` | `docs:document.comment:delete` |
|
||||
| `file.comment.replys.list` | `docs:document.comment:read` |
|
||||
| `file.comment.replys.update` | `docs:document.comment:update` |
|
||||
| `permission.members.auth` | `docs:permission.member:auth` |
|
||||
| `permission.members.create` | `docs:permission.member:create` |
|
||||
| `permission.members.transfer_owner` | `docs:permission.member:transfer` |
|
||||
| `permission.public.get` | `docs:permission.setting:read` |
|
||||
| `permission.public.patch` | `docs:permission.setting:write_only` |
|
||||
| `metas.batch_query` | `drive:drive.metadata:readonly` |
|
||||
| `user.remove_subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription_status` | `docs:event:subscribe` |
|
||||
| `file.statistics.get` | `drive:drive.metadata:readonly` |
|
||||
| `file.view_records.list` | `drive:file:view_record:readonly` |
|
||||
| `file.comment.reply.reactions.update_reaction` | `docs:document.comment:create` |
|
||||
| `quota_details.get` | `drive:quota_detail:read_one` |
|
||||
|
||||
> `quota_details.get` 是 user-only OpenAPI:调用时必须显式传 `--as user`,且 `quota_detail_id` 应填写当前用户的 `user_id`。
|
||||
| `metas.batch_query` | `drive:drive.metadata:readonly` |
|
||||
| `user.remove_subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription_status` | `docs:event:subscribe` |
|
||||
| `file.statistics.get` | `drive:drive.metadata:readonly` |
|
||||
| `file.view_records.list` | `drive:file:view_record:readonly` |
|
||||
| `file.comment.reply.reactions.update_reaction` | `docs:document.comment:create` |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user