mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
16 Commits
feat-histo
...
v1.0.55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
714da970d0 | ||
|
|
ed7fdd1a27 | ||
|
|
4464ba7660 | ||
|
|
bb03c8ac4d | ||
|
|
3feb70b32a | ||
|
|
64b1b3f3ed | ||
|
|
a0e83c7e59 | ||
|
|
297b2a222e | ||
|
|
80a5f30f4d | ||
|
|
cf35d1e499 | ||
|
|
fd16cf106b | ||
|
|
53076733ec | ||
|
|
a3bee13ca9 | ||
|
|
6217bd2c29 | ||
|
|
72c294712c | ||
|
|
37f4f899b2 |
42
CHANGELOG.md
42
CHANGELOG.md
@@ -2,6 +2,46 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.55] - 2026-06-16
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Support agent meeting event workflows (#1483)
|
||||
- **drive**: Support exporting Base structure snapshots (#1481)
|
||||
- **doc**: Add docx cover resource commands (#1468)
|
||||
- **doc**: Support `lang` for docx fetch v2 (#1459)
|
||||
- **event**: Optimize subscription precheck, links, and consumer guard (#1447)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Validate drive import folder target (#1485)
|
||||
|
||||
## [v1.0.54] - 2026-06-15
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Auto-attach default signature on send/reply/forward (#1415)
|
||||
- **drive**: Support `original_creator_ids` filter in search (#1046)
|
||||
- **cli**: Simplify proxy plugin warning and gate it on TTY (#1448)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Fix docs fetch and update ergonomics (#1466)
|
||||
- **vfs**: Reject blank local paths (#1460)
|
||||
- **vfs**: Reject Windows absolute paths cross-platform (#1401)
|
||||
- **event**: Clarify remote bus blocker recovery (#1454)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Converge command pipelines onto a typed metadata model + catalog (#1191)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Document `@mention` format per message type (text/post/card) (#1419)
|
||||
- **doc**: Clarify lark-doc create title guidance (#1474)
|
||||
- **skills**: Add rename prompt for import without `--name` (#1461)
|
||||
- **apps**: Drop Miaoda brand word from apps command help text (#1399)
|
||||
|
||||
## [v1.0.53] - 2026-06-12
|
||||
|
||||
### Features
|
||||
@@ -1149,6 +1189,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
|
||||
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen the host alternation when adding brands.
|
||||
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
|
||||
|
||||
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.
|
||||
|
||||
@@ -4,21 +4,117 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
|
||||
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
|
||||
host, appID, strings.Join(scopes, ","))
|
||||
// Landing-page contract for the scan-to-enable deep link, verified against the
|
||||
// open platform: {open-host}/page/launcher?clientID=<appID>&addons=<encoded>.
|
||||
// Note the param is camelCase "clientID" (not snake_case), and the value is the
|
||||
// consuming app's own ID. Centralized so it can be corrected in one place.
|
||||
const (
|
||||
addonsLandingPath = "/page/launcher"
|
||||
addonsClientIDParam = "clientID"
|
||||
)
|
||||
|
||||
// ManifestAddons mirrors the 5 public manifest sections the launcher page accepts.
|
||||
// Encoded form: JSON -> gzip -> base64url(no padding).
|
||||
type ManifestAddons struct {
|
||||
Scopes *AddonsScopes `json:"scopes,omitempty"`
|
||||
Events *AddonsEvents `json:"events,omitempty"`
|
||||
Callbacks *AddonsCallbacks `json:"callbacks,omitempty"`
|
||||
}
|
||||
|
||||
// consoleEventSubscriptionURL points at the app's event subscription console page.
|
||||
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/event", host, appID)
|
||||
type AddonsScopes struct {
|
||||
Tenant []string `json:"tenant"`
|
||||
User []string `json:"user"`
|
||||
}
|
||||
|
||||
type AddonsEvents struct {
|
||||
Items AddonsEventItems `json:"items"`
|
||||
}
|
||||
|
||||
type AddonsEventItems struct {
|
||||
Tenant []string `json:"tenant"`
|
||||
User []string `json:"user"`
|
||||
}
|
||||
|
||||
type AddonsCallbacks struct {
|
||||
Items []string `json:"items"`
|
||||
}
|
||||
|
||||
// encodeAddons: JSON -> gzip -> base64url(no padding). Matches the front-end decode chain.
|
||||
func encodeAddons(a ManifestAddons) (string, error) {
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
if _, err := gw.Write(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
|
||||
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
|
||||
encoded, err := encodeAddons(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
|
||||
}
|
||||
|
||||
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
|
||||
func consoleLandingURL(brand core.LarkBrand, appID string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
|
||||
}
|
||||
|
||||
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
|
||||
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
|
||||
url, err := consoleAddonsURL(brand, appID, a)
|
||||
if err != nil {
|
||||
return consoleLandingURL(brand, appID)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// missingScopeAddons routes missing scopes into the identity-appropriate section.
|
||||
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
|
||||
// the addons spec treats a missing tenant/user as an empty array.
|
||||
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
|
||||
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
|
||||
if identity.IsBot() {
|
||||
s.Tenant = missing
|
||||
} else {
|
||||
s.User = missing
|
||||
}
|
||||
return ManifestAddons{Scopes: s}
|
||||
}
|
||||
|
||||
// missingSubscriptionAddons routes missing events/callbacks into the right section.
|
||||
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
|
||||
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
|
||||
if subType == eventlib.SubTypeCallback {
|
||||
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
|
||||
}
|
||||
ev := &AddonsEvents{Items: AddonsEventItems{Tenant: []string{}, User: []string{}}}
|
||||
if identity.IsBot() {
|
||||
ev.Items.Tenant = missing
|
||||
} else {
|
||||
ev.Items.User = missing
|
||||
}
|
||||
return ManifestAddons{Events: ev}
|
||||
}
|
||||
|
||||
@@ -4,33 +4,109 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
|
||||
"im:message:readonly",
|
||||
"im:message.group_at_msg",
|
||||
})
|
||||
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
|
||||
t.Helper()
|
||||
gz, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("base64url decode: %v", err)
|
||||
}
|
||||
zr, err := gzip.NewReader(bytes.NewReader(gz))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip reader: %v", err)
|
||||
}
|
||||
raw, err := io.ReadAll(zr)
|
||||
if err != nil {
|
||||
t.Fatalf("gunzip: %v", err)
|
||||
}
|
||||
var a ManifestAddons
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func TestEncodeAddons_RoundTrip(t *testing.T) {
|
||||
in := ManifestAddons{Scopes: &AddonsScopes{Tenant: []string{"im:message"}}}
|
||||
encoded, err := encodeAddons(in)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
for _, r := range encoded {
|
||||
if !(r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
|
||||
t.Fatalf("encoded contains non-base64url char %q in %q", r, encoded)
|
||||
}
|
||||
}
|
||||
out := decodeAddons(t, encoded)
|
||||
if out.Scopes == nil || len(out.Scopes.Tenant) != 1 || out.Scopes.Tenant[0] != "im:message" {
|
||||
t.Errorf("roundtrip mismatch: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
|
||||
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
|
||||
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("url: %v", err)
|
||||
}
|
||||
host := core.ResolveEndpoints(core.BrandFeishu).Open
|
||||
prefix := host + "/page/launcher?clientID=cli_x&addons="
|
||||
if !strings.HasPrefix(url, prefix) {
|
||||
t.Errorf("url = %q, want prefix %q", url, prefix)
|
||||
}
|
||||
out := decodeAddons(t, strings.TrimPrefix(url, prefix))
|
||||
if out.Callbacks == nil || len(out.Callbacks.Items) != 1 || out.Callbacks.Items[0] != "card.action.trigger" {
|
||||
t.Errorf("decoded callbacks mismatch: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
|
||||
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
|
||||
t.Errorf("unexpected url: %s", got)
|
||||
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
|
||||
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
|
||||
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
|
||||
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
|
||||
}
|
||||
user := missingScopeAddons(core.AsUser, []string{"im:message"})
|
||||
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
|
||||
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
|
||||
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
|
||||
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
|
||||
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
|
||||
}
|
||||
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
|
||||
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
|
||||
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
|
||||
// Unused identity sides must encode as [] (not null) so the launcher page's
|
||||
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
|
||||
cases := []ManifestAddons{
|
||||
missingScopeAddons(core.AsBot, []string{"im:message"}),
|
||||
missingScopeAddons(core.AsUser, []string{"im:message"}),
|
||||
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
|
||||
}
|
||||
for i, a := range cases {
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("case %d marshal: %v", i, err)
|
||||
}
|
||||
if bytes.Contains(raw, []byte("null")) {
|
||||
t.Errorf("case %d encodes a null array, want []: %s", i, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,14 +146,28 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
|
||||
}
|
||||
|
||||
// Callback subscriptions live in application/get, not app_versions; fetch the
|
||||
// callback 底账 only for callback-type EventKeys. Weak dependency: on error,
|
||||
// leave subscribedCallbacks nil so the callback precheck skips.
|
||||
var subscribedCallbacks []string
|
||||
if keyDef.SubscriptionType == eventlib.SubTypeCallback {
|
||||
cbs, cbErr := appmeta.FetchSubscribedCallbacks(cmd.Context(), botRuntime, cfg.AppID)
|
||||
if cbErr != nil {
|
||||
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(cbErr))
|
||||
} else {
|
||||
subscribedCallbacks = cbs
|
||||
}
|
||||
}
|
||||
|
||||
pf := &preflightCtx{
|
||||
factory: f,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
eventKey: eventKey,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
factory: f,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
eventKey: eventKey,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
subscribedCallbacks: subscribedCallbacks,
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
return err
|
||||
@@ -229,6 +243,9 @@ type preflightCtx struct {
|
||||
identity core.Identity
|
||||
keyDef *eventlib.KeyDefinition
|
||||
appVer *appmeta.AppVersion
|
||||
// subscribedCallbacks is the application/get 底账 for callback-type EventKeys;
|
||||
// nil means "not fetched / unavailable" → callback precheck skips (weak dependency).
|
||||
subscribedCallbacks []string
|
||||
}
|
||||
|
||||
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
|
||||
@@ -266,46 +283,66 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||
WithIdentity(string(pf.identity)).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
|
||||
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
|
||||
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which
|
||||
// the tenant token carries them. User: the scan link only updates the app
|
||||
// manifest — the user's own token still lacks the scopes until it is
|
||||
// re-authorized — so direct the user to re-login instead.
|
||||
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
|
||||
if identity.IsBot() {
|
||||
return fmt.Sprintf(
|
||||
"grant these scopes and publish a new app version at: %s",
|
||||
consoleScopeGrantURL(brand, appID, missing),
|
||||
)
|
||||
return fmt.Sprintf("grant these scopes by scanning: %s",
|
||||
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
|
||||
strings.Join(missing, " "),
|
||||
)
|
||||
strings.Join(missing, " "))
|
||||
}
|
||||
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
|
||||
// in the app's console 底账 — published app_versions for event subscriptions,
|
||||
// application/get subscribed_callbacks for callback subscriptions.
|
||||
func preflightEventTypes(pf *preflightCtx) error {
|
||||
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
if len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
return nil
|
||||
}
|
||||
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
|
||||
for _, t := range pf.appVer.EventTypes {
|
||||
subscribed[t] = true
|
||||
|
||||
var subscribed []string
|
||||
noun := "event types"
|
||||
if pf.keyDef.SubscriptionType == eventlib.SubTypeCallback {
|
||||
if pf.subscribedCallbacks == nil {
|
||||
return nil
|
||||
}
|
||||
subscribed = pf.subscribedCallbacks
|
||||
noun = "callbacks"
|
||||
} else {
|
||||
if pf.appVer == nil {
|
||||
return nil
|
||||
}
|
||||
subscribed = pf.appVer.EventTypes
|
||||
}
|
||||
|
||||
have := make(map[string]bool, len(subscribed))
|
||||
for _, t := range subscribed {
|
||||
have[t] = true
|
||||
}
|
||||
var missing []string
|
||||
for _, t := range pf.keyDef.RequiredConsoleEvents {
|
||||
if !subscribed[t] {
|
||||
if !have[t] {
|
||||
missing = append(missing, t)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing))
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")).
|
||||
WithHint("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID))
|
||||
"EventKey %s requires %s not subscribed in console: %s",
|
||||
pf.keyDef.Key, noun, strings.Join(missing, ", ")).
|
||||
WithHint("subscribe these %s by scanning: %s", noun, url)
|
||||
}
|
||||
|
||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||
|
||||
@@ -97,9 +97,9 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
|
||||
if !strings.Contains(p.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
|
||||
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,9 +157,8 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
}
|
||||
hint := permErr.Hint
|
||||
wantSubstrings := []string{
|
||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||
"im:message.group_at_msg",
|
||||
"token_type=tenant",
|
||||
"grant these scopes by scanning: ",
|
||||
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=",
|
||||
}
|
||||
for _, want := range wantSubstrings {
|
||||
if !strings.Contains(hint, want) {
|
||||
@@ -174,3 +173,109 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
|
||||
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{"profile.view.get"},
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
err := preflightEventTypes(pf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing callback")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "callbacks not subscribed") {
|
||||
t.Errorf("error = %q, want mention of 'callbacks not subscribed'", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "card.action.trigger") {
|
||||
t.Errorf("error should name the missing callback, got: %q", err.Error())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %v, want validation/failed_precondition", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
t.Errorf("expected skip (nil), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
|
||||
// fetched but zero callbacks subscribed (non-nil empty) is a definitive
|
||||
// console state: a required callback IS missing and must be reported,
|
||||
// not skipped as a weak dependency.
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{}, // fetched, none subscribed
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
err := preflightEventTypes(pf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing callback when none are subscribed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "card.action.trigger") {
|
||||
t.Errorf("error should name the missing callback, got: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
t.Errorf("all callbacks subscribed, unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
|
||||
// bot: scan-to-enable link (adds scopes to app manifest)
|
||||
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
|
||||
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
|
||||
t.Errorf("bot hint should give the scan link, got: %s", bot)
|
||||
}
|
||||
// user: re-login (scan link cannot grant scopes to the user's own token)
|
||||
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
|
||||
if !strings.Contains(user, "auth login --scope") {
|
||||
t.Errorf("user hint should direct to auth login, got: %s", user)
|
||||
}
|
||||
if strings.Contains(user, "/page/launcher") {
|
||||
t.Errorf("user hint must NOT use the scan link, got: %s", user)
|
||||
}
|
||||
}
|
||||
|
||||
12
go.mod
12
go.mod
@@ -27,8 +27,6 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require github.com/apache/arrow/go/v17 v17.0.0
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
@@ -44,17 +42,13 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.6 // indirect
|
||||
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
@@ -63,16 +57,10 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/smarty/assertions v1.15.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
|
||||
golang.org/x/mod v0.18.0 // indirect
|
||||
golang.org/x/tools v0.22.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||
)
|
||||
|
||||
32
go.sum
32
go.sum
@@ -2,8 +2,6 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/apache/arrow/go/v17 v17.0.0 h1:RRR2bdqKcdbss9Gxy2NS/hK8i4LDMh23L6BbkN5+F54=
|
||||
github.com/apache/arrow/go/v17 v17.0.0/go.mod h1:jR7QHkODl15PfYyjM2nU+yTLScZ/qfj7OSUZmJ8putc=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
@@ -54,16 +52,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
|
||||
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
@@ -80,16 +74,11 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
@@ -108,8 +97,6 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
@@ -146,20 +133,14 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
|
||||
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -175,7 +156,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
@@ -189,16 +169,10 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
|
||||
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ=
|
||||
gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
47
internal/appmeta/app_callbacks.go
Normal file
47
internal/appmeta/app_callbacks.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package appmeta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// FetchSubscribedCallbacks returns the app's currently subscribed callback names
|
||||
// from application/get. On a successful fetch it always returns a non-nil slice
|
||||
// (empty when callback_info is absent or lists no callbacks) so callers can
|
||||
// distinguish "fetched, zero callbacks subscribed" — a definitive console state
|
||||
// that must fail the precheck — from a fetch error (nil), which is a
|
||||
// weak-dependency skip. Identity must be bot: the endpoint is app-level.
|
||||
func FetchSubscribedCallbacks(ctx context.Context, client APIClient, appID string) ([]string, error) {
|
||||
path := fmt.Sprintf("/open-apis/application/v6/applications/%s?lang=zh_cn", appID)
|
||||
raw, err := client.CallAPI(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
App struct {
|
||||
CallbackInfo *struct {
|
||||
SubscribedCallbacks []string `json:"subscribed_callbacks"`
|
||||
} `json:"callback_info"`
|
||||
} `json:"app"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("decode application response: %w", err)
|
||||
}
|
||||
// callback_info also carries callback_type (e.g. "websocket"); it is
|
||||
// intentionally not parsed or validated. Feishu open-platform callbacks are
|
||||
// delivered over WebSocket only (confirmed), matching the CLI's WebSocket
|
||||
// event source, so subscribed_callbacks alone is sufficient for the precheck.
|
||||
// Revisit and validate callback_type if non-WebSocket delivery ever appears.
|
||||
callbacks := []string{}
|
||||
if ci := envelope.Data.App.CallbackInfo; ci != nil {
|
||||
callbacks = append(callbacks, ci.SubscribedCallbacks...)
|
||||
}
|
||||
return callbacks, nil
|
||||
}
|
||||
101
internal/appmeta/app_callbacks_test.go
Normal file
101
internal/appmeta/app_callbacks_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package appmeta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var errFakeFetch = errors.New("fake fetch error")
|
||||
|
||||
type fakeCallbackClient struct {
|
||||
raw string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeCallbackClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return json.RawMessage(f.raw), nil
|
||||
}
|
||||
|
||||
func TestFetchSubscribedCallbacks_ParsesList(t *testing.T) {
|
||||
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket","subscribed_callbacks":["card.action.trigger","profile.view.get"]}}},"msg":"success"}`
|
||||
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
want := []string{"card.action.trigger", "profile.view.get"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSubscribedCallbacks_NoCallbackInfo(t *testing.T) {
|
||||
// A successful fetch with no callback_info means "zero callbacks subscribed",
|
||||
// which must be a non-nil empty slice (distinct from a fetch error's nil) so
|
||||
// the precheck reports a required callback as missing instead of skipping.
|
||||
raw := `{"code":0,"data":{"app":{"app_id":"cli_x"}},"msg":"success"}`
|
||||
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("got nil, want non-nil empty slice")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSubscribedCallbacks_FetchError(t *testing.T) {
|
||||
// A fetch error must return nil so the caller treats it as a weak-dependency skip.
|
||||
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{err: errFakeFetch}, "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("got %v, want nil on fetch error", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSubscribedCallbacks_CallbackInfoPresentButNull(t *testing.T) {
|
||||
// callback_info present but subscribed_callbacks explicitly null → must be
|
||||
// a non-nil empty slice so the precheck reports missing callbacks.
|
||||
raw := `{"code":0,"data":{"app":{"callback_info":{"subscribed_callbacks":null}}},"msg":"success"}`
|
||||
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is null")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSubscribedCallbacks_CallbackInfoPresentButOmitted(t *testing.T) {
|
||||
// callback_info present but subscribed_callbacks omitted → same as null: non-nil empty.
|
||||
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket"}}},"msg":"success"}`
|
||||
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is omitted")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %v, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -265,8 +265,8 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
|
||||
AppID: app.AppId,
|
||||
AppSecret: secret,
|
||||
Brand: app.Brand,
|
||||
DefaultAs: app.DefaultAs,
|
||||
Lang: app.Lang,
|
||||
DefaultAs: app.DefaultAs,
|
||||
}
|
||||
if len(app.Users) > 0 {
|
||||
cfg.UserOpenId = app.Users[0].UserOpenId
|
||||
|
||||
@@ -132,6 +132,27 @@ func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_CarriesLang(t *testing.T) {
|
||||
raw := &MultiAppConfig{
|
||||
Apps: []AppConfig{
|
||||
{
|
||||
AppId: "cli_abc",
|
||||
AppSecret: PlainSecret("my-secret"),
|
||||
Brand: BrandFeishu,
|
||||
Lang: "en",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := ResolveConfigFromMulti(raw, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Lang != "en" {
|
||||
t.Errorf("Lang = %q, want %q", cfg.Lang, "en")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T) {
|
||||
// Keychain ref matches appId, so validation passes.
|
||||
// The subsequent ResolveSecretInput will fail (no real keychain),
|
||||
|
||||
@@ -269,8 +269,26 @@ func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.H
|
||||
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID, subID)
|
||||
bc.SetLogger(b.logger)
|
||||
|
||||
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
|
||||
firstForKey := b.hub.RegisterAndIsFirst(bc)
|
||||
// SingleConsumer EventKeys allow only one consumer per SubscriptionID: reject extras at handshake.
|
||||
exclusive := false
|
||||
if def, ok := event.Lookup(hello.EventKey); ok {
|
||||
exclusive = def.SingleConsumer
|
||||
}
|
||||
var firstForKey bool
|
||||
if exclusive {
|
||||
ok, reason := b.hub.TryRegisterExclusive(bc)
|
||||
if !ok {
|
||||
if err := bc.writeFrame(protocol.NewHelloAckRejected("v1", reason)); err != nil {
|
||||
b.logger.Printf("WARN: reject hello_ack write to pid=%d key=%q failed: %v", hello.PID, hello.EventKey, err)
|
||||
}
|
||||
bc.Close()
|
||||
return
|
||||
}
|
||||
firstForKey = true
|
||||
} else {
|
||||
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
|
||||
firstForKey = b.hub.RegisterAndIsFirst(bc)
|
||||
}
|
||||
|
||||
bc.SetCheckLastForKey(func(scope string) bool {
|
||||
return b.hub.AcquireCleanupLock(scope)
|
||||
|
||||
@@ -5,12 +5,15 @@ package bus
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
@@ -194,3 +197,60 @@ func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) {
|
||||
t.Fatal("HelloAck was empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleHello_SingleConsumerRejectsSecond: a SingleConsumer EventKey accepts
|
||||
// the first consumer and rejects the second for the same SubscriptionID.
|
||||
func TestHandleHello_SingleConsumerRejectsSecond(t *testing.T) {
|
||||
const key = "test.handlehello.exclusive"
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
SingleConsumer: true,
|
||||
Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
|
||||
})
|
||||
defer event.UnregisterKeyForTest(key)
|
||||
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
idleTimer: time.NewTimer(30 * time.Second),
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
readAck := func(t *testing.T, pid int) *protocol.HelloAck {
|
||||
t.Helper()
|
||||
server, client := net.Pipe()
|
||||
t.Cleanup(func() { server.Close(); client.Close() })
|
||||
hello := &protocol.Hello{PID: pid, EventKey: key, EventTypes: []string{key}}
|
||||
go b.handleHello(server, bufio.NewReader(server), hello)
|
||||
line, err := protocol.ReadFrame(bufio.NewReader(client))
|
||||
if err != nil {
|
||||
t.Fatalf("read ack (pid %d): %v", pid, err)
|
||||
}
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("decode ack (pid %d): %v", pid, err)
|
||||
}
|
||||
ack, ok := msg.(*protocol.HelloAck)
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want *HelloAck", msg)
|
||||
}
|
||||
return ack
|
||||
}
|
||||
|
||||
ack1 := readAck(t, 100)
|
||||
if ack1.Rejected {
|
||||
t.Fatalf("first consumer should be accepted, got rejected: %q", ack1.RejectReason)
|
||||
}
|
||||
|
||||
ack2 := readAck(t, 200)
|
||||
if !ack2.Rejected {
|
||||
t.Fatal("second consumer should be rejected")
|
||||
}
|
||||
if !strings.Contains(ack2.RejectReason, "already running") {
|
||||
t.Errorf("reject reason = %q, want mention of 'already running'", ack2.RejectReason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,34 @@ package bus
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// exclusiveCleanupWaitTimeout bounds how long TryRegisterExclusive waits for an
|
||||
// in-progress cleanup of the same subscription before rejecting, so a stuck
|
||||
// cleanup can never wedge new consumers forever. Kept below the consumer's
|
||||
// hello_ack deadline (consume.helloAckTimeout = 5s) so the reject still reaches
|
||||
// the consumer as a clean failed_precondition instead of a handshake timeout.
|
||||
// Override with LARKSUITE_CLI_EVENT_EXCLUSIVE_WAIT_TIMEOUT (a Go duration such as
|
||||
// "2s"); values at or above the 5s handshake deadline are not recommended.
|
||||
var exclusiveCleanupWaitTimeout = resolveExclusiveCleanupWaitTimeout()
|
||||
|
||||
func resolveExclusiveCleanupWaitTimeout() time.Duration {
|
||||
const def = 3 * time.Second
|
||||
if v := os.Getenv("LARKSUITE_CLI_EVENT_EXCLUSIVE_WAIT_TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Subscriber is the interface a connection must satisfy for Hub registration.
|
||||
type Subscriber interface {
|
||||
EventKey() string
|
||||
@@ -124,6 +145,63 @@ func (h *Hub) RegisterAndIsFirst(s Subscriber) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// TryRegisterExclusive registers s only when no subscriber holds s.SubscriptionID()
|
||||
// and any in-progress cleanup for that subscription finishes within
|
||||
// exclusiveCleanupWaitTimeout. On failure it returns (false, reason): either a
|
||||
// duplicate consumer already holds the subscription, or the cleanup did not
|
||||
// finish in time — the timeout guarantees a stuck cleanup can never wedge new
|
||||
// consumers forever. reason is "" on success. Mirrors RegisterAndIsFirst's wait
|
||||
// on in-progress cleanup, but bounded.
|
||||
func (h *Hub) TryRegisterExclusive(s Subscriber) (bool, string) {
|
||||
sid := s.SubscriptionID()
|
||||
deadline := time.Now().Add(exclusiveCleanupWaitTimeout)
|
||||
for {
|
||||
h.mu.Lock()
|
||||
ch, locked := h.cleanupInProgress[sid]
|
||||
if locked {
|
||||
h.mu.Unlock()
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return false, "timed out waiting for the previous consumer's cleanup to finish; retry shortly"
|
||||
}
|
||||
timer := time.NewTimer(remaining)
|
||||
select {
|
||||
case <-ch:
|
||||
// Stop+drain so a timer that fired concurrently with Stop isn't left on .C.
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
continue
|
||||
case <-timer.C:
|
||||
return false, "timed out waiting for the previous consumer's cleanup to finish; retry shortly"
|
||||
}
|
||||
}
|
||||
if h.subCounts[sid] != 0 {
|
||||
pid := h.existingPIDForSubscriptionLocked(sid)
|
||||
h.mu.Unlock()
|
||||
return false, fmt.Sprintf("another consumer (pid %d) is already running for this subscription", pid)
|
||||
}
|
||||
h.subscribers[s] = struct{}{}
|
||||
h.subCounts[sid]++
|
||||
h.mu.Unlock()
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
|
||||
// existingPIDForSubscriptionLocked returns the PID of one subscriber for sid.
|
||||
// Caller must hold h.mu.
|
||||
func (h *Hub) existingPIDForSubscriptionLocked(sid string) int {
|
||||
for sub := range h.subscribers {
|
||||
if sub.SubscriptionID() == sid {
|
||||
return sub.PID()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Publish fans out a RawEvent to all matching subscribers (non-blocking).
|
||||
//
|
||||
// A fresh *protocol.Event is allocated per subscriber so each consumer sees
|
||||
|
||||
@@ -6,6 +6,7 @@ package bus
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -355,3 +356,69 @@ func TestHub_Consumers_PopulatesSubscriptionID(t *testing.T) {
|
||||
t.Errorf("Consumers()[0].SubscriptionID = %q, want %q", consumers[0].SubscriptionID, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_TryRegisterExclusive(t *testing.T) {
|
||||
h := NewHub()
|
||||
first := newTestConn("k.exclusive", []string{"k.exclusive"})
|
||||
first.pid = 100
|
||||
ok, _ := h.TryRegisterExclusive(first)
|
||||
if !ok {
|
||||
t.Fatal("first exclusive register should succeed")
|
||||
}
|
||||
|
||||
second := newTestConn("k.exclusive", []string{"k.exclusive"})
|
||||
second.pid = 200
|
||||
ok, reason := h.TryRegisterExclusive(second)
|
||||
if ok {
|
||||
t.Error("second exclusive register should be rejected")
|
||||
}
|
||||
if !strings.Contains(reason, "pid 100") {
|
||||
t.Errorf("reject reason = %q, want it to name existing pid 100", reason)
|
||||
}
|
||||
if got := h.SubCount("k.exclusive"); got != 1 {
|
||||
t.Errorf("SubCount = %d, want 1 (second not registered)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_TryRegisterExclusive_CleanupWaitTimeout(t *testing.T) {
|
||||
// A cleanup lock that never releases must not wedge a new exclusive consumer
|
||||
// forever — TryRegisterExclusive bounds the wait and rejects with a timeout reason.
|
||||
saved := exclusiveCleanupWaitTimeout
|
||||
exclusiveCleanupWaitTimeout = 20 * time.Millisecond
|
||||
defer func() { exclusiveCleanupWaitTimeout = saved }()
|
||||
|
||||
h := NewHub()
|
||||
first := newTestConn("k.timeout", []string{"k.timeout"})
|
||||
if ok, _ := h.TryRegisterExclusive(first); !ok {
|
||||
t.Fatal("first exclusive register should succeed")
|
||||
}
|
||||
// Hold the cleanup lock and never release it.
|
||||
if !h.AcquireCleanupLock("k.timeout") {
|
||||
t.Fatal("AcquireCleanupLock should succeed for the sole subscriber")
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
second := newTestConn("k.timeout", []string{"k.timeout"})
|
||||
ok, reason := h.TryRegisterExclusive(second)
|
||||
if ok {
|
||||
t.Error("second exclusive register should be rejected on cleanup-wait timeout")
|
||||
}
|
||||
if !strings.Contains(reason, "timed out") {
|
||||
t.Errorf("reject reason = %q, want a timeout reason", reason)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Errorf("wait took %v, want bounded by the ~20ms timeout (no deadlock)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_TryRegisterExclusive_DistinctSubscriptions(t *testing.T) {
|
||||
h := NewHub()
|
||||
a := newTestConn("k.a", []string{"k.a"})
|
||||
b := newTestConn("k.b", []string{"k.b"})
|
||||
if ok, _ := h.TryRegisterExclusive(a); !ok {
|
||||
t.Fatal("register a failed")
|
||||
}
|
||||
if ok, _ := h.TryRegisterExclusive(b); !ok {
|
||||
t.Error("distinct subscription b should register")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
@@ -102,6 +103,9 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus handshake failed: %s", err).WithCause(err)
|
||||
}
|
||||
if rejErr := rejectionError(ack, opts.EventKey); rejErr != nil {
|
||||
return rejErr
|
||||
}
|
||||
|
||||
var cleanup func() error
|
||||
if ack.FirstForKey && keyDef.PreConsume != nil {
|
||||
@@ -171,6 +175,17 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
return consumeLoop(ctx, conn, br, keyDef, opts, subscriptionID, &lastForKey, &emitted)
|
||||
}
|
||||
|
||||
// rejectionError converts a rejected hello_ack into a structured precondition
|
||||
// error; returns nil when the ack is absent or not a rejection.
|
||||
func rejectionError(ack *protocol.HelloAck, eventKey string) error {
|
||||
if ack == nil || !ack.Rejected {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"cannot start consumer: %s", ack.RejectReason).
|
||||
WithHint("EventKey %s allows only one consumer; run `lark-cli event status` to find the running one, then stop it before retrying", eventKey)
|
||||
}
|
||||
|
||||
func truncateDuration(d time.Duration) time.Duration {
|
||||
return d.Truncate(time.Second)
|
||||
}
|
||||
|
||||
36
internal/event/consume/reject_test.go
Normal file
36
internal/event/consume/reject_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
func TestRejectionError_Rejected(t *testing.T) {
|
||||
ack := &protocol.HelloAck{Type: protocol.MsgTypeHelloAck, Rejected: true, RejectReason: "another consumer (pid 9) is already running"}
|
||||
err := rejectionError(ack, "im.message.receive_v1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rejected ack")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok || prob.Category != errs.CategoryValidation || prob.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %v, want validation/failed_precondition; err=%q", prob, err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already running") {
|
||||
t.Errorf("error = %q, want reject reason", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectionError_NotRejected(t *testing.T) {
|
||||
if err := rejectionError(&protocol.HelloAck{Type: protocol.MsgTypeHelloAck}, "k"); err != nil {
|
||||
t.Errorf("expected nil for non-rejected ack, got %v", err)
|
||||
}
|
||||
if err := rejectionError(nil, "k"); err != nil {
|
||||
t.Errorf("expected nil for nil ack, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -53,8 +53,8 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
|
||||
if count > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"another event bus is already connected to this app (%d active connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
|
||||
WithHint("use `lark-cli event status` to check, or `lark-cli event stop` on the other machine first")
|
||||
"another event bus is already connected to this app (%d remote event connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
|
||||
WithHint("remote event connection detected; `lark-cli event status` and `lark-cli event stop` only inspect local buses; stop the owner host/process, wait for the platform connection timeout, or use a separate app/profile")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -50,8 +50,16 @@ func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event stop") {
|
||||
t.Errorf("hint should point at `event stop`, got: %q", ve.Hint)
|
||||
wantHints := []string{
|
||||
"remote event connection",
|
||||
"`lark-cli event status` and `lark-cli event stop` only inspect local buses",
|
||||
"stop the owner host/process",
|
||||
"wait for the platform connection timeout",
|
||||
}
|
||||
for _, want := range wantHints {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint missing %q\ngot: %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,9 +43,11 @@ type Hello struct {
|
||||
}
|
||||
|
||||
type HelloAck struct {
|
||||
Type string `json:"type"`
|
||||
BusVersion string `json:"bus_version"`
|
||||
FirstForKey bool `json:"first_for_key"`
|
||||
Type string `json:"type"`
|
||||
BusVersion string `json:"bus_version"`
|
||||
FirstForKey bool `json:"first_for_key"`
|
||||
Rejected bool `json:"rejected,omitempty"`
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
}
|
||||
|
||||
// Event: Seq is per-conn monotonic; gaps signal bus drop-oldest backpressure loss.
|
||||
@@ -117,6 +119,17 @@ func NewHelloAck(busVersion string, firstForKey bool) *HelloAck {
|
||||
}
|
||||
}
|
||||
|
||||
// NewHelloAckRejected builds a hello_ack that tells the consumer the bus refused
|
||||
// registration (e.g. a SingleConsumer EventKey already has a running consumer).
|
||||
func NewHelloAckRejected(busVersion, reason string) *HelloAck {
|
||||
return &HelloAck{
|
||||
Type: MsgTypeHelloAck,
|
||||
BusVersion: busVersion,
|
||||
Rejected: true,
|
||||
RejectReason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func NewEvent(eventType, eventID, sourceTime string, seq uint64, payload json.RawMessage) *Event {
|
||||
return &Event{
|
||||
Type: MsgTypeEvent,
|
||||
|
||||
@@ -105,3 +105,25 @@ func TestReadFrame_PropagatesEOF(t *testing.T) {
|
||||
t.Errorf("err = %v, want io.EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelloAckRejected_RoundTrip(t *testing.T) {
|
||||
ack := NewHelloAckRejected("v1", "another consumer (pid 42) is already running for this subscription")
|
||||
if !ack.Rejected || ack.RejectReason == "" {
|
||||
t.Fatalf("NewHelloAckRejected fields: %+v", ack)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := Encode(&buf, ack); err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
msg, err := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
got, ok := msg.(*HelloAck)
|
||||
if !ok {
|
||||
t.Fatalf("decoded type = %T, want *HelloAck", msg)
|
||||
}
|
||||
if !got.Rejected || got.RejectReason != ack.RejectReason {
|
||||
t.Errorf("roundtrip = %+v, want Rejected with reason", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,14 @@ func RegisterKey(def KeyDefinition) {
|
||||
panic(fmt.Sprintf("EventKey %s: EventType must not be empty", def.Key))
|
||||
}
|
||||
|
||||
if def.SubscriptionType == "" {
|
||||
def.SubscriptionType = SubTypeEvent
|
||||
}
|
||||
if def.SubscriptionType != SubTypeEvent && def.SubscriptionType != SubTypeCallback {
|
||||
panic(fmt.Sprintf("EventKey %s: SubscriptionType must be %q or %q; got %q",
|
||||
def.Key, SubTypeEvent, SubTypeCallback, def.SubscriptionType))
|
||||
}
|
||||
|
||||
validateSchema(def)
|
||||
validateParams(def)
|
||||
validateAuth(def)
|
||||
|
||||
@@ -244,3 +244,58 @@ func TestBufferSize_Clamped(t *testing.T) {
|
||||
t.Errorf("BufferSize = %d, want %d", def.BufferSize, MaxBufferSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterKey_SubscriptionTypeDefaultsToEvent(t *testing.T) {
|
||||
const key = "test.subtype.default"
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
|
||||
})
|
||||
defer UnregisterKeyForTest(key)
|
||||
|
||||
def, ok := Lookup(key)
|
||||
if !ok {
|
||||
t.Fatalf("Lookup(%q) failed", key)
|
||||
}
|
||||
if def.SubscriptionType != SubTypeEvent {
|
||||
t.Errorf("SubscriptionType = %q, want %q", def.SubscriptionType, SubTypeEvent)
|
||||
}
|
||||
if def.SingleConsumer {
|
||||
t.Errorf("SingleConsumer = true, want false (default)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterKey_SubscriptionTypeCallbackPreserved(t *testing.T) {
|
||||
const key = "test.subtype.callback"
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
SubscriptionType: SubTypeCallback,
|
||||
SingleConsumer: true,
|
||||
Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
|
||||
})
|
||||
defer UnregisterKeyForTest(key)
|
||||
|
||||
def, _ := Lookup(key)
|
||||
if def.SubscriptionType != SubTypeCallback {
|
||||
t.Errorf("SubscriptionType = %q, want %q", def.SubscriptionType, SubTypeCallback)
|
||||
}
|
||||
if !def.SingleConsumer {
|
||||
t.Errorf("SingleConsumer = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterKey_InvalidSubscriptionTypePanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("expected panic for invalid SubscriptionType")
|
||||
}
|
||||
}()
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: "test.subtype.bogus",
|
||||
EventType: "test.subtype.bogus",
|
||||
SubscriptionType: "bogus",
|
||||
Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,6 +42,18 @@ const (
|
||||
ParamInt ParamType = "int"
|
||||
)
|
||||
|
||||
// SubscriptionType marks whether an EventKey is delivered via Lark event
|
||||
// subscription or interactive callback subscription. It is a sibling of
|
||||
// EventType (which holds the concrete Lark event_type string).
|
||||
type SubscriptionType string
|
||||
|
||||
const (
|
||||
// SubTypeEvent: checked against the published app_versions event_infos.
|
||||
SubTypeEvent SubscriptionType = "event"
|
||||
// SubTypeCallback: checked against application/get subscribed_callbacks.
|
||||
SubTypeCallback SubscriptionType = "callback"
|
||||
)
|
||||
|
||||
// ParamValue.Desc is mandatory so AI consumers can decide which value to pick.
|
||||
type ParamValue struct {
|
||||
Value string `json:"value"`
|
||||
@@ -96,6 +108,10 @@ type KeyDefinition struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
EventType string `json:"event_type"`
|
||||
|
||||
// SubscriptionType selects which console "底账" the precheck reads.
|
||||
// Empty is normalized to SubTypeEvent at RegisterKey.
|
||||
SubscriptionType SubscriptionType `json:"subscription_type,omitempty"`
|
||||
|
||||
Params []ParamDef `json:"params,omitempty"`
|
||||
|
||||
Schema SchemaDef `json:"schema"`
|
||||
@@ -148,4 +164,8 @@ type KeyDefinition struct {
|
||||
|
||||
BufferSize int `json:"buffer_size,omitempty"`
|
||||
Workers int `json:"workers,omitempty"`
|
||||
|
||||
// SingleConsumer rejects a second consumer for the same SubscriptionID at
|
||||
// the bus handshake. Default false = unlimited consumers (fan-out).
|
||||
SingleConsumer bool `json:"single_consumer,omitempty"`
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
|
||||
{"unicode normal", "报告.xlsx", false},
|
||||
{"dot-dot resolves to cwd", "subdir/..", false},
|
||||
|
||||
// ── GIVEN: empty or blank paths → THEN: rejected ──
|
||||
{"empty path", "", true},
|
||||
{"blank path", " ", true},
|
||||
|
||||
// ── GIVEN: path traversal via .. → THEN: rejected ──
|
||||
{"dot-dot escape", "../../.ssh/authorized_keys", true},
|
||||
{"dot-dot mid path", "subdir/../../etc/passwd", true},
|
||||
|
||||
@@ -60,6 +60,10 @@ func safePath(raw, flagName string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return "", fmt.Errorf("%s must not be empty", flagName)
|
||||
}
|
||||
|
||||
if isAbsolutePath(raw) {
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
|
||||
{"unicode normal", "报告.xlsx", false},
|
||||
{"dot-dot resolves to cwd", "subdir/..", false},
|
||||
|
||||
// ── GIVEN: empty or blank paths → THEN: rejected ──
|
||||
{"empty path", "", true},
|
||||
{"blank path", " ", true},
|
||||
|
||||
// ── GIVEN: path traversal via .. → THEN: rejected ──
|
||||
{"dot-dot escape", "../../.ssh/authorized_keys", true},
|
||||
{"dot-dot mid path", "subdir/../../etc/passwd", true},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.53",
|
||||
"version": "1.0.55",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
795
shortcuts/doc/doc_resource_cover.go
Normal file
795
shortcuts/doc/doc_resource_cover.go
Normal file
@@ -0,0 +1,795 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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 (
|
||||
docCoverResourceType = "cover"
|
||||
docCoverUploadParent = "docx_image"
|
||||
docCoverURLMaxBytes = int64(20 * 1024 * 1024)
|
||||
docCoverDownloadName = "cover"
|
||||
docCoverURLDownloadName = "cover"
|
||||
)
|
||||
|
||||
type docCoverHTTPStatusCause int
|
||||
|
||||
func (c docCoverHTTPStatusCause) Error() string {
|
||||
return http.StatusText(int(c))
|
||||
}
|
||||
|
||||
type docCoverURLGuardError string
|
||||
|
||||
func (e docCoverURLGuardError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
var docCoverAllowedContentTypes = map[string]string{
|
||||
"image/gif": ".gif",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
var DocResourceDownload = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "resource-download",
|
||||
Description: "Download a document resource (type=cover downloads the cover image content)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly", "docs:document.media:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or document_id", Required: true},
|
||||
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
|
||||
{Name: "output", Desc: "local save path", Required: true},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: validateDocCoverType,
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
documentID := docRef.Token
|
||||
d := common.NewDryRunAPI()
|
||||
if docRef.Kind == "wiki" {
|
||||
documentID = "<resolved_docx_token>"
|
||||
d.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to docx document").
|
||||
Params(map[string]interface{}{"token": docRef.Token})
|
||||
}
|
||||
d.GET("/open-apis/docx/v1/documents/:document_id").
|
||||
Desc("Read document cover metadata").
|
||||
Set("document_id", documentID)
|
||||
d.GET("/open-apis/drive/v1/medias/:cover_token/download").
|
||||
Desc("Download cover image content").
|
||||
Set("cover_token", "<cover.token>").
|
||||
Set("output", runtime.Str("output"))
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
documentID, err := resolveDocxDocumentIDForResource(runtime, runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputPath := runtime.Str("output")
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
cover, err := getDocCover(runtime, documentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cover.Token == "" {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "document has no cover (cover is empty): %s", common.MaskToken(documentID)).WithParam("--type")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Downloading cover: %s\n", common.MaskToken(cover.Token))
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/download", validate.EncodePathSegment(cover.Token)),
|
||||
})
|
||||
if err != nil {
|
||||
return wrapDocNetworkErr(err, "download cover failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, "")
|
||||
if finalPath != outputPath {
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
}
|
||||
if !runtime.Bool("overwrite") {
|
||||
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output")
|
||||
}
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
savedPath, _ := runtime.ResolveSavePath(finalPath)
|
||||
if savedPath == "" {
|
||||
savedPath = finalPath
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"document_id": documentID,
|
||||
"type": docCoverResourceType,
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": result.Size(),
|
||||
"content_type": resp.Header.Get("Content-Type"),
|
||||
"cover": cover.toOutput(),
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocResourceUpdate = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "resource-update",
|
||||
Description: "Upload and update a document resource (type=cover)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:readonly", "docx:document:write_only", "docs:document.media:upload"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or document_id", Required: true},
|
||||
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
|
||||
{Name: "file", Desc: "local image file path (files > 20MB use multipart upload automatically)"},
|
||||
{Name: "from-clipboard", Type: "bool", Desc: "read image from system clipboard instead of a local file"},
|
||||
{Name: "url", Desc: "HTTPS image URL to download and upload"},
|
||||
{Name: "offset-ratio-x", Type: "float64", Desc: "cover horizontal offset ratio"},
|
||||
{Name: "offset-ratio-y", Type: "float64", Desc: "cover vertical offset ratio"},
|
||||
},
|
||||
Validate: validateDocCoverUpdate,
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
documentID := docRef.Token
|
||||
d := common.NewDryRunAPI()
|
||||
if docRef.Kind == "wiki" {
|
||||
documentID = "<resolved_docx_token>"
|
||||
d.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to docx document").
|
||||
Params(map[string]interface{}{"token": docRef.Token})
|
||||
}
|
||||
source := docCoverDryRunSource(runtime)
|
||||
d.Desc("upload cover image and update document cover").
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("Upload cover image").
|
||||
Body(map[string]interface{}{
|
||||
"file": source,
|
||||
"file_name": "<cover_file_name>",
|
||||
"parent_type": docCoverUploadParent,
|
||||
"parent_node": documentID,
|
||||
"extra": fmt.Sprintf(`{"drive_route_token":"%s"}`, documentID),
|
||||
})
|
||||
d.PATCH("/open-apis/docx/v1/documents/:document_id").
|
||||
Desc("Update document cover").
|
||||
Body(map[string]interface{}{"update_cover": map[string]interface{}{"cover": buildDocCoverUpdateBody("<file_token>", runtime)}})
|
||||
d.Set("document_id", documentID)
|
||||
if runtime.Str("url") != "" {
|
||||
d.Set("url_safety", "HTTPS only; private/loopback/link-local IPs rejected; max 3 redirects; image content-types only; max 20MiB")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
documentID, err := resolveDocxDocumentIDForResource(runtime, runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source, err := readDocCoverUpdateSource(ctx, runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading cover image: %s (%d bytes)\n", source.FileName, source.FileSize)
|
||||
if source.FileSize > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
uploadCfg := UploadDocMediaFileConfig{
|
||||
FilePath: source.FilePath,
|
||||
Reader: source.Reader,
|
||||
FileName: source.FileName,
|
||||
FileSize: source.FileSize,
|
||||
ParentType: docCoverUploadParent,
|
||||
ParentNode: documentID,
|
||||
DocID: documentID,
|
||||
}
|
||||
fileToken, err := uploadDocMediaFile(runtime, uploadCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File uploaded: %s\n", common.MaskToken(fileToken))
|
||||
|
||||
coverBody := buildDocCoverUpdateBody(fileToken, runtime)
|
||||
if _, err := runtime.CallAPITyped("PATCH",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s", validate.EncodePathSegment(documentID)),
|
||||
nil,
|
||||
map[string]interface{}{"update_cover": map[string]interface{}{"cover": coverBody}},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"document_id": documentID,
|
||||
"type": docCoverResourceType,
|
||||
"updated": true,
|
||||
"source": source.Kind,
|
||||
"file_token": fileToken,
|
||||
"cover": coverBody,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocResourceDelete = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "resource-delete",
|
||||
Description: "Delete a document resource (type=cover is idempotent when empty)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:readonly", "docx:document:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or document_id", Required: true},
|
||||
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
|
||||
},
|
||||
Validate: validateDocCoverType,
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
documentID := docRef.Token
|
||||
d := common.NewDryRunAPI()
|
||||
if docRef.Kind == "wiki" {
|
||||
documentID = "<resolved_docx_token>"
|
||||
d.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to docx document").
|
||||
Params(map[string]interface{}{"token": docRef.Token})
|
||||
}
|
||||
d.GET("/open-apis/docx/v1/documents/:document_id").
|
||||
Desc("Read document cover metadata for idempotency").
|
||||
Set("document_id", documentID)
|
||||
d.PATCH("/open-apis/docx/v1/documents/:document_id").
|
||||
Desc("Clear document cover when one exists").
|
||||
Body(map[string]interface{}{"update_cover": map[string]interface{}{"cover": nil}})
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
documentID, err := resolveDocxDocumentIDForResource(runtime, runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cover, err := getDocCover(runtime, documentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cover.Token == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"document_id": documentID,
|
||||
"type": docCoverResourceType,
|
||||
"deleted": false,
|
||||
"already_empty": true,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := runtime.CallAPITyped("PATCH",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s", validate.EncodePathSegment(documentID)),
|
||||
nil,
|
||||
map[string]interface{}{"update_cover": map[string]interface{}{"cover": nil}},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"document_id": documentID,
|
||||
"type": docCoverResourceType,
|
||||
"deleted": true,
|
||||
"already_empty": false,
|
||||
"previous_cover": cover.toOutput(),
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type docCoverMetadata struct {
|
||||
Token string
|
||||
OffsetRatioX *float64
|
||||
OffsetRatioY *float64
|
||||
}
|
||||
|
||||
func (c docCoverMetadata) toOutput() map[string]interface{} {
|
||||
out := map[string]interface{}{"token": c.Token}
|
||||
if c.OffsetRatioX != nil {
|
||||
out["offset_ratio_x"] = *c.OffsetRatioX
|
||||
}
|
||||
if c.OffsetRatioY != nil {
|
||||
out["offset_ratio_y"] = *c.OffsetRatioY
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type docCoverUpdateSource struct {
|
||||
Kind string
|
||||
FilePath string
|
||||
Reader io.Reader
|
||||
FileName string
|
||||
FileSize int64
|
||||
}
|
||||
|
||||
func validateDocCoverType(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("type") != docCoverResourceType {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --type %q, expected cover", runtime.Str("type")).WithParam("--type")
|
||||
}
|
||||
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if docRef.Kind == "doc" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "docs resource-* only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocCoverUpdate(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateDocCoverType(ctx, runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceCount := 0
|
||||
var params []errs.InvalidParam
|
||||
if runtime.Str("file") != "" {
|
||||
sourceCount++
|
||||
params = append(params, errs.InvalidParam{Name: "--file", Reason: "source flag"})
|
||||
}
|
||||
if runtime.Bool("from-clipboard") {
|
||||
sourceCount++
|
||||
params = append(params, errs.InvalidParam{Name: "--from-clipboard", Reason: "source flag"})
|
||||
}
|
||||
if runtime.Str("url") != "" {
|
||||
sourceCount++
|
||||
params = append(params, errs.InvalidParam{Name: "--url", Reason: "source flag"})
|
||||
}
|
||||
if sourceCount == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --file, --from-clipboard or --url is required").WithParams(
|
||||
errs.InvalidParam{Name: "--file", Reason: "provide one source"},
|
||||
errs.InvalidParam{Name: "--from-clipboard", Reason: "provide one source"},
|
||||
errs.InvalidParam{Name: "--url", Reason: "provide one source"},
|
||||
)
|
||||
}
|
||||
if sourceCount > 1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file, --from-clipboard and --url are mutually exclusive").WithParams(params...)
|
||||
}
|
||||
if err := validateCoverOffset(runtime, "offset-ratio-x"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCoverOffset(runtime, "offset-ratio-y"); err != nil {
|
||||
return err
|
||||
}
|
||||
if rawURL := runtime.Str("url"); rawURL != "" {
|
||||
if _, err := parseDocCoverURLSyntax(rawURL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCoverOffset(runtime *common.RuntimeContext, name string) error {
|
||||
if !runtime.Changed(name) {
|
||||
return nil
|
||||
}
|
||||
value := runtime.Float64(name)
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be a finite number", name).WithParam("--" + name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDocxDocumentIDForResource(runtime *common.RuntimeContext, input string) (string, error) {
|
||||
docRef, err := parseDocumentRef(input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch docRef.Kind {
|
||||
case "docx":
|
||||
return docRef.Token, nil
|
||||
case "wiki":
|
||||
return resolveDocxDocumentID(runtime, input)
|
||||
case "doc":
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs resource-* only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
|
||||
default:
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs resource-* only supports docx documents").WithParam("--doc")
|
||||
}
|
||||
}
|
||||
|
||||
func getDocCover(runtime *common.RuntimeContext, documentID string) (docCoverMetadata, error) {
|
||||
data, err := runtime.CallAPITyped("GET",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s", validate.EncodePathSegment(documentID)),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return docCoverMetadata{}, err
|
||||
}
|
||||
coverData := common.GetMap(data, "document", "cover")
|
||||
if len(coverData) == 0 {
|
||||
coverData = common.GetMap(data, "cover")
|
||||
}
|
||||
cover := docCoverMetadata{Token: common.GetString(coverData, "token")}
|
||||
if value, ok := getOptionalFloat(coverData, "offset_ratio_x"); ok {
|
||||
cover.OffsetRatioX = &value
|
||||
}
|
||||
if value, ok := getOptionalFloat(coverData, "offset_ratio_y"); ok {
|
||||
cover.OffsetRatioY = &value
|
||||
}
|
||||
return cover, nil
|
||||
}
|
||||
|
||||
func getOptionalFloat(m map[string]interface{}, key string) (float64, bool) {
|
||||
if m == nil {
|
||||
return 0, false
|
||||
}
|
||||
switch v := m[key].(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func readDocCoverUpdateSource(ctx context.Context, runtime *common.RuntimeContext) (docCoverUpdateSource, error) {
|
||||
if runtime.Bool("from-clipboard") {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Reading image from clipboard...\n")
|
||||
content, err := readClipboardImage()
|
||||
if err != nil {
|
||||
return docCoverUpdateSource{}, err
|
||||
}
|
||||
return docCoverUpdateSource{
|
||||
Kind: "clipboard",
|
||||
Reader: bytes.NewReader(content),
|
||||
FileName: "clipboard.png",
|
||||
FileSize: int64(len(content)),
|
||||
}, nil
|
||||
}
|
||||
if rawURL := runtime.Str("url"); rawURL != "" {
|
||||
content, fileName, err := downloadDocCoverURL(ctx, runtime, rawURL)
|
||||
if err != nil {
|
||||
return docCoverUpdateSource{}, err
|
||||
}
|
||||
return docCoverUpdateSource{
|
||||
Kind: "url",
|
||||
Reader: bytes.NewReader(content),
|
||||
FileName: fileName,
|
||||
FileSize: int64(len(content)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
filePath := runtime.Str("file")
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return docCoverUpdateSource{}, wrapDocInputFileErr(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return docCoverUpdateSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
|
||||
}
|
||||
return docCoverUpdateSource{
|
||||
Kind: "file",
|
||||
FilePath: filePath,
|
||||
FileName: filepath.Base(filePath),
|
||||
FileSize: stat.Size(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildDocCoverUpdateBody(fileToken string, runtime *common.RuntimeContext) map[string]interface{} {
|
||||
cover := map[string]interface{}{"token": fileToken}
|
||||
if runtime.Changed("offset-ratio-x") {
|
||||
cover["offset_ratio_x"] = runtime.Float64("offset-ratio-x")
|
||||
}
|
||||
if runtime.Changed("offset-ratio-y") {
|
||||
cover["offset_ratio_y"] = runtime.Float64("offset-ratio-y")
|
||||
}
|
||||
return cover
|
||||
}
|
||||
|
||||
func docCoverDryRunSource(runtime *common.RuntimeContext) string {
|
||||
if runtime.Bool("from-clipboard") {
|
||||
return "<clipboard image>"
|
||||
}
|
||||
if rawURL := runtime.Str("url"); rawURL != "" {
|
||||
return rawURL
|
||||
}
|
||||
if filePath := runtime.Str("file"); filePath != "" {
|
||||
return "@" + filePath
|
||||
}
|
||||
return "<cover image>"
|
||||
}
|
||||
|
||||
func downloadDocCoverURL(ctx context.Context, runtime *common.RuntimeContext, raw string) ([]byte, string, error) {
|
||||
u, err := parseAndValidateDocCoverURL(ctx, raw)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
baseClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
|
||||
}
|
||||
client := newDocCoverHTTPClient(baseClient)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) //nolint:forbidigo // cover --url fetches external user content; RuntimeContext API helpers are Lark-API only.
|
||||
if err != nil {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --url: %v", err).WithParam("--url").WithCause(err)
|
||||
}
|
||||
resp, err := client.Do(req) //nolint:forbidigo // cover --url uses a guarded external downloader, not Lark API transport.
|
||||
if err != nil {
|
||||
return nil, "", wrapDocNetworkErr(err, "download cover URL failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
if resp.StatusCode >= 500 {
|
||||
subtype = errs.SubtypeNetworkServer
|
||||
}
|
||||
cause := docCoverHTTPStatusCause(resp.StatusCode)
|
||||
return nil, "", errs.NewNetworkError(subtype, "download cover URL failed: HTTP %d", resp.StatusCode).WithCode(resp.StatusCode).WithCause(cause)
|
||||
}
|
||||
|
||||
mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
if err != nil || mediaType == "" {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL response must include an image Content-Type").WithParam("--url")
|
||||
}
|
||||
mediaType = strings.ToLower(mediaType)
|
||||
ext, ok := docCoverAllowedContentTypes[mediaType]
|
||||
if !ok {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL Content-Type %q is not supported; expected image/png, image/jpeg, image/gif or image/webp", mediaType).WithParam("--url")
|
||||
}
|
||||
|
||||
limited := io.LimitReader(resp.Body, docCoverURLMaxBytes+1)
|
||||
content, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, "", wrapDocNetworkErr(err, "read cover URL response failed: %v", err)
|
||||
}
|
||||
if int64(len(content)) > docCoverURLMaxBytes {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL response exceeds 20MiB limit").WithParam("--url")
|
||||
}
|
||||
|
||||
fileName := docCoverURLFileName(resp.Request.URL, ext)
|
||||
return content, fileName, nil
|
||||
}
|
||||
|
||||
func parseAndValidateDocCoverURL(ctx context.Context, raw string) (*url.URL, error) {
|
||||
u, err := parseDocCoverURLSyntax(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDocCoverURLHost(ctx, u.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func parseDocCoverURLSyntax(raw string) (*url.URL, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --url: %v", err).WithParam("--url").WithCause(err)
|
||||
}
|
||||
if u.Scheme != "https" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must use https").WithParam("--url")
|
||||
}
|
||||
if u.User != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not include userinfo").WithParam("--url")
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url host cannot be empty").WithParam("--url")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func docCoverURLFileName(u *url.URL, ext string) string {
|
||||
base := path.Base(u.EscapedPath())
|
||||
if base == "." || base == "/" || base == "" {
|
||||
return docCoverURLDownloadName + ext
|
||||
}
|
||||
unescaped, err := url.PathUnescape(base)
|
||||
if err == nil {
|
||||
base = unescaped
|
||||
}
|
||||
base = filepath.Base(base)
|
||||
if strings.TrimSpace(base) == "" || base == "." || base == string(filepath.Separator) {
|
||||
return docCoverURLDownloadName + ext
|
||||
}
|
||||
if filepath.Ext(base) == "" {
|
||||
base += ext
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func validateDocCoverURLHost(ctx context.Context, host string) error {
|
||||
host = strings.TrimSpace(strings.ToLower(host))
|
||||
if host == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url host cannot be empty").WithParam("--url")
|
||||
}
|
||||
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not resolve to a local or internal address").WithParam("--url")
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if isUnsafeDocCoverIP(ip) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not resolve to a local or internal address").WithParam("--url")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to resolve --url host: %v", err).WithParam("--url").WithCause(err)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to resolve --url host: no addresses").WithParam("--url")
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if isUnsafeDocCoverIP(ip) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not resolve to a local or internal address").WithParam("--url")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isUnsafeDocCoverIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
if v4[0] == 10 || v4[0] == 127 {
|
||||
return true
|
||||
}
|
||||
if v4[0] == 169 && v4[1] == 254 {
|
||||
return true
|
||||
}
|
||||
if v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31 {
|
||||
return true
|
||||
}
|
||||
if v4[0] == 192 && v4[1] == 168 {
|
||||
return true
|
||||
}
|
||||
if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
|
||||
return true
|
||||
}
|
||||
if v4[0] == 198 && (v4[1] == 18 || v4[1] == 19) {
|
||||
return true
|
||||
}
|
||||
if v4[0] >= 240 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return ip.IsPrivate()
|
||||
}
|
||||
|
||||
func newDocCoverHTTPClient(base *http.Client) *http.Client { //nolint:forbidigo // guarded external --url downloader cannot use Lark API runtime helpers.
|
||||
if base == nil {
|
||||
base = &http.Client{} //nolint:forbidigo // fallback only; caller normally supplies Factory.HttpClient.
|
||||
}
|
||||
cloned := *base
|
||||
if cloned.Timeout == 0 { //nolint:forbidigo // external download timeout guard on cloned client.
|
||||
cloned.Timeout = 30 * time.Second //nolint:forbidigo // external download timeout guard on cloned client.
|
||||
}
|
||||
cloned.Transport = cloneDocCoverTransport(base.Transport) //nolint:forbidigo // external download transport adds proxy/IP guards.
|
||||
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error { //nolint:forbidigo // redirects must be validated for external --url downloads.
|
||||
if len(via) >= 3 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL redirects too many times").WithParam("--url")
|
||||
}
|
||||
if len(via) > 0 {
|
||||
prev := via[len(via)-1]
|
||||
if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL redirect from https to http is not allowed").WithParam("--url")
|
||||
}
|
||||
}
|
||||
_, err := parseAndValidateDocCoverURL(req.Context(), req.URL.String())
|
||||
return err
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneDocCoverTransport(base http.RoundTripper) *http.Transport { //nolint:forbidigo // external --url downloader wraps caller transport with IP/proxy guards.
|
||||
var cloned *http.Transport
|
||||
if src, ok := base.(*http.Transport); ok && src != nil {
|
||||
cloned = src.Clone()
|
||||
} else if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil { //nolint:forbidigo // fallback for guarded external downloader only.
|
||||
cloned = def.Clone()
|
||||
} else {
|
||||
cloned = &http.Transport{}
|
||||
}
|
||||
cloned.Proxy = nil
|
||||
|
||||
origDial := cloned.DialContext
|
||||
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialDocCoverConn(ctx, origDial, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
if cloned.DialTLSContext != nil {
|
||||
origDialTLS := cloned.DialTLSContext
|
||||
cloned.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialDocCoverConn(ctx, origDialTLS, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func dialDocCoverConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
|
||||
if dialFn != nil {
|
||||
return dialFn(ctx, network, addr)
|
||||
}
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
func validateDocCoverConnRemoteIP(conn net.Conn) error {
|
||||
if conn == nil {
|
||||
return docCoverURLGuardError("nil connection")
|
||||
}
|
||||
addr := conn.RemoteAddr()
|
||||
if addr == nil {
|
||||
return docCoverURLGuardError("missing remote address")
|
||||
}
|
||||
host, _, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
host = addr.String()
|
||||
}
|
||||
ip := net.ParseIP(strings.Trim(host, "[]"))
|
||||
if ip == nil {
|
||||
return docCoverURLGuardError("invalid remote IP")
|
||||
}
|
||||
if isUnsafeDocCoverIP(ip) {
|
||||
return docCoverURLGuardError("local/internal host is not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
712
shortcuts/doc/doc_resource_cover_test.go
Normal file
712
shortcuts/doc/doc_resource_cover_test.go
Normal file
@@ -0,0 +1,712 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestDocResourceDownloadCoverDownloadsImageContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-download-app"))
|
||||
documentID := "doxcnCoverDownload1"
|
||||
coverToken := "cover_token_download_123"
|
||||
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{
|
||||
"token": coverToken,
|
||||
"offset_ratio_x": 0.25,
|
||||
"offset_ratio_y": 0.75,
|
||||
}))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/" + coverToken + "/download",
|
||||
Status: 200,
|
||||
Body: []byte("png-data"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"image/png"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDownload, []string{
|
||||
"resource-download",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--output", "cover",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeDocResourceOutput(t, stdout)
|
||||
data := out.Data
|
||||
if data["type"] != "cover" {
|
||||
t.Fatalf("type = %v, want cover", data["type"])
|
||||
}
|
||||
if data["content_type"] != "image/png" {
|
||||
t.Fatalf("content_type = %v, want image/png", data["content_type"])
|
||||
}
|
||||
if int(data["size_bytes"].(float64)) != len("png-data") {
|
||||
t.Fatalf("size_bytes = %v", data["size_bytes"])
|
||||
}
|
||||
savedPath, _ := data["saved_path"].(string)
|
||||
if !strings.HasSuffix(savedPath, "cover.png") {
|
||||
t.Fatalf("saved_path = %q, want cover.png suffix", savedPath)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(tmpDir, "cover.png"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(cover.png) error: %v", err)
|
||||
}
|
||||
if string(content) != "png-data" {
|
||||
t.Fatalf("downloaded content = %q", string(content))
|
||||
}
|
||||
cover := data["cover"].(map[string]interface{})
|
||||
if cover["token"] != coverToken {
|
||||
t.Fatalf("cover.token = %v, want %s", cover["token"], coverToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocResourceDownloadCoverEmptyReturnsErrorWithoutDownload(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-empty-download-app"))
|
||||
documentID := "doxcnCoverEmptyDownload1"
|
||||
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{}))
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDownload, []string{
|
||||
"resource-download",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--output", "cover.png",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty cover error, got nil")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeFailedPrecondition, "--type")
|
||||
if _, statErr := os.Stat(filepath.Join(tmpDir, "cover.png")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("cover.png should not be created, statErr=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocResourceDeleteCoverEmptyIsIdempotent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-empty-delete-app"))
|
||||
documentID := "doxcnCoverEmptyDelete1"
|
||||
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{}))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDelete, []string{
|
||||
"resource-delete",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocResourceOutput(t, stdout).Data
|
||||
if data["deleted"] != false {
|
||||
t.Fatalf("deleted = %v, want false", data["deleted"])
|
||||
}
|
||||
if data["already_empty"] != true {
|
||||
t.Fatalf("already_empty = %v, want true", data["already_empty"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocResourceDeleteCoverClearsExistingCover(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-delete-app"))
|
||||
documentID := "doxcnCoverDelete1"
|
||||
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{"token": "cover_token_delete_123"}))
|
||||
patchStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/docx/v1/documents/" + documentID,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
}
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDelete, []string{
|
||||
"resource-delete",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := string(patchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"update_cover"`) || !strings.Contains(body, `"cover":null`) {
|
||||
t.Fatalf("PATCH body = %s, want update_cover.cover=null", body)
|
||||
}
|
||||
data := decodeDocResourceOutput(t, stdout).Data
|
||||
if data["deleted"] != true {
|
||||
t.Fatalf("deleted = %v, want true", data["deleted"])
|
||||
}
|
||||
if data["already_empty"] != false {
|
||||
t.Fatalf("already_empty = %v, want false", data["already_empty"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocResourceUpdateCoverUploadsFileAndReturnsFullTokenOnlyOnStdout(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-update-app"))
|
||||
documentID := "doxcnCoverUpdate1"
|
||||
fileToken := "file_cover_uploaded_token_12345"
|
||||
tmpDir := t.TempDir()
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("cover.png", []byte("png-data"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": fileToken},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
patchStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/docx/v1/documents/" + documentID,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
}
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--file", "cover.png",
|
||||
"--offset-ratio-x", "0.2",
|
||||
"--offset-ratio-y", "0.8",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v — stderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
if !bytes.Contains(uploadStub.CapturedBody, []byte("png-data")) {
|
||||
t.Fatalf("upload body does not contain file bytes")
|
||||
}
|
||||
uploadBody := string(uploadStub.CapturedBody)
|
||||
if !strings.Contains(uploadBody, `name="parent_type"`) || !strings.Contains(uploadBody, "docx_image") {
|
||||
t.Fatalf("upload body missing docx_image parent type: %s", uploadBody)
|
||||
}
|
||||
if !strings.Contains(uploadBody, "drive_route_token") || !strings.Contains(uploadBody, documentID) {
|
||||
t.Fatalf("upload body missing drive_route_token extra: %s", uploadBody)
|
||||
}
|
||||
patchBody := string(patchStub.CapturedBody)
|
||||
for _, want := range []string{`"update_cover"`, `"token":"` + fileToken + `"`, `"offset_ratio_x":0.2`, `"offset_ratio_y":0.8`} {
|
||||
if !strings.Contains(patchBody, want) {
|
||||
t.Fatalf("PATCH body = %s, missing %s", patchBody, want)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(stderr.String(), fileToken) {
|
||||
t.Fatalf("stderr leaked full file_token: %s", stderr.String())
|
||||
}
|
||||
data := decodeDocResourceOutput(t, stdout).Data
|
||||
if data["file_token"] != fileToken {
|
||||
t.Fatalf("stdout file_token = %v, want %s", data["file_token"], fileToken)
|
||||
}
|
||||
cover := data["cover"].(map[string]interface{})
|
||||
if cover["token"] != fileToken {
|
||||
t.Fatalf("stdout cover.token = %v, want %s", cover["token"], fileToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocResourceUpdateCoverRejectsMultipleSources(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-validation-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"--doc", "doxcnCoverValidate1",
|
||||
"--type", "cover",
|
||||
"--file", "cover.png",
|
||||
"--from-clipboard",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected mutual exclusion error, got nil")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--file", "--from-clipboard")
|
||||
}
|
||||
|
||||
func TestDocResourceUpdateCoverRejectsMissingSource(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-required-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"--doc", "doxcnCoverValidateRequired1",
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing source error, got nil")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--file", "--from-clipboard", "--url")
|
||||
}
|
||||
|
||||
func TestDocResourceUpdateCoverRejectsUnsafeURLSource(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-url-validation-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"--doc", "doxcnCoverURLValidate1",
|
||||
"--type", "cover",
|
||||
"--url", "https://127.0.0.1/cover.png",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe URL error, got nil")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
|
||||
}
|
||||
|
||||
func TestDocCoverURLSyntaxValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
ok bool
|
||||
}{
|
||||
{name: "https", raw: " https://example.com/cover.png ", ok: true},
|
||||
{name: "http", raw: "http://example.com/cover.png"},
|
||||
{name: "userinfo", raw: "https://user:pass@example.com/cover.png"},
|
||||
{name: "empty host", raw: "https:///cover.png"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
u, err := parseDocCoverURLSyntax(tc.raw)
|
||||
if tc.ok {
|
||||
if err != nil {
|
||||
t.Fatalf("parseDocCoverURLSyntax() error: %v", err)
|
||||
}
|
||||
if u.String() != "https://example.com/cover.png" {
|
||||
t.Fatalf("URL = %q, want normalized https URL", u.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocResourceCoverDryRunsPlanAPIs(t *testing.T) {
|
||||
downloadRT := docValidateRuntime(t, map[string]string{
|
||||
"doc": "doxcnCoverDryRunDownload",
|
||||
"output": "cover",
|
||||
}, nil, nil)
|
||||
download := decodeDocDryRun(t, DocResourceDownload.DryRun(context.Background(), downloadRT))
|
||||
if len(download.API) != 2 {
|
||||
t.Fatalf("download dry-run API count = %d, want 2", len(download.API))
|
||||
}
|
||||
if got := download.API[0].URL; got != "/open-apis/docx/v1/documents/doxcnCoverDryRunDownload" {
|
||||
t.Fatalf("download metadata URL = %q", got)
|
||||
}
|
||||
if got := download.API[1].URL; got != "/open-apis/drive/v1/medias/%3Ccover.token%3E/download" {
|
||||
t.Fatalf("download media URL = %q", got)
|
||||
}
|
||||
|
||||
updateRT := docValidateRuntime(t, map[string]string{
|
||||
"doc": "https://example.larksuite.com/wiki/wikcnCoverDryRunUpdate",
|
||||
"url": "https://example.com/cover.png",
|
||||
}, nil, nil)
|
||||
update := decodeDocDryRun(t, DocResourceUpdate.DryRun(context.Background(), updateRT))
|
||||
if len(update.API) != 3 {
|
||||
t.Fatalf("update dry-run API count = %d, want 3", len(update.API))
|
||||
}
|
||||
if got := update.API[0].URL; got != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("wiki resolve URL = %q", got)
|
||||
}
|
||||
if got := update.API[1].URL; got != "/open-apis/drive/v1/medias/upload_all" {
|
||||
t.Fatalf("upload URL = %q", got)
|
||||
}
|
||||
if got := update.API[2].URL; got != "/open-apis/docx/v1/documents/%3Cresolved_docx_token%3E" {
|
||||
t.Fatalf("patch URL = %q", got)
|
||||
}
|
||||
if got := update.API[1].Body["file"]; got != "https://example.com/cover.png" {
|
||||
t.Fatalf("upload source = %#v", got)
|
||||
}
|
||||
|
||||
deleteRT := docValidateRuntime(t, map[string]string{"doc": "doxcnCoverDryRunDelete"}, nil, nil)
|
||||
deleteDry := decodeDocDryRun(t, DocResourceDelete.DryRun(context.Background(), deleteRT))
|
||||
if len(deleteDry.API) != 2 {
|
||||
t.Fatalf("delete dry-run API count = %d, want 2", len(deleteDry.API))
|
||||
}
|
||||
if got := deleteDry.API[1].URL; got != "/open-apis/docx/v1/documents/doxcnCoverDryRunDelete" {
|
||||
t.Fatalf("delete patch URL = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocResourceCoverDryRunReportsInvalidDoc(t *testing.T) {
|
||||
rt := docValidateRuntime(t, map[string]string{"doc": "https://example.com/sheets/shtxxx"}, nil, nil)
|
||||
dry := DocResourceDownload.DryRun(context.Background(), rt)
|
||||
if got := dry.Format(); !strings.Contains(got, "error:") {
|
||||
t.Fatalf("dry-run error output = %q, want error field", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateDocCoverURLRejectsUnsafeIP(t *testing.T) {
|
||||
_, err := parseAndValidateDocCoverURL(context.Background(), "https://127.0.0.1/cover.png")
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
|
||||
}
|
||||
|
||||
func TestValidateDocCoverURLHost(t *testing.T) {
|
||||
for _, host := range []string{"", "localhost", "service.localhost", "127.0.0.1"} {
|
||||
t.Run(host, func(t *testing.T) {
|
||||
assertValidationContract(t, validateDocCoverURLHost(context.Background(), host), errs.SubtypeInvalidArgument, "--url")
|
||||
})
|
||||
}
|
||||
if err := validateDocCoverURLHost(context.Background(), "1.1.1.1"); err != nil {
|
||||
t.Fatalf("validateDocCoverURLHost(public IP) error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverIPSafetyBlocksSpecialRanges(t *testing.T) {
|
||||
for _, rawIP := range []string{
|
||||
"10.0.0.1",
|
||||
"127.0.0.1",
|
||||
"169.254.1.1",
|
||||
"172.16.0.1",
|
||||
"192.168.0.1",
|
||||
"100.64.0.1",
|
||||
"198.18.0.1",
|
||||
"240.0.0.1",
|
||||
} {
|
||||
t.Run(rawIP, func(t *testing.T) {
|
||||
if !isUnsafeDocCoverIP(net.ParseIP(rawIP)) {
|
||||
t.Fatalf("%s was classified as safe", rawIP)
|
||||
}
|
||||
})
|
||||
}
|
||||
if isUnsafeDocCoverIP(net.ParseIP("1.1.1.1")) {
|
||||
t.Fatal("public IPv4 address was classified as unsafe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverHTTPClientDoesNotUseProxy(t *testing.T) {
|
||||
baseTransport := &http.Transport{Proxy: http.ProxyFromEnvironment}
|
||||
baseClient := &http.Client{Transport: baseTransport}
|
||||
|
||||
client := newDocCoverHTTPClient(baseClient)
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client transport = %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
if transport.Proxy != nil {
|
||||
t.Fatal("cover URL downloader must not inherit proxy settings")
|
||||
}
|
||||
if baseTransport.Proxy == nil {
|
||||
t.Fatal("base transport proxy was mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverHTTPClientRedirectValidation(t *testing.T) {
|
||||
client := newDocCoverHTTPClient(&http.Client{})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://1.1.1.1/cover.png", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest() error: %v", err)
|
||||
}
|
||||
if err := client.CheckRedirect(req, []*http.Request{{}, {}, {}}); err == nil {
|
||||
t.Fatal("expected too many redirects error")
|
||||
}
|
||||
|
||||
prev, err := http.NewRequest(http.MethodGet, "https://1.1.1.1/start", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest(prev) error: %v", err)
|
||||
}
|
||||
downgrade, err := http.NewRequest(http.MethodGet, "http://1.1.1.1/cover.png", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest(downgrade) error: %v", err)
|
||||
}
|
||||
if err := client.CheckRedirect(downgrade, []*http.Request{prev}); err == nil {
|
||||
t.Fatal("expected https-to-http redirect error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverConnRemoteIPValidation(t *testing.T) {
|
||||
if err := validateDocCoverConnRemoteIP(nil); err == nil {
|
||||
t.Fatal("expected nil connection error")
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{}); err == nil {
|
||||
t.Fatal("expected missing remote address error")
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{addr: testAddr("not-ip")}); err == nil {
|
||||
t.Fatal("expected invalid remote IP error")
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 443}}); err == nil {
|
||||
t.Fatal("expected local remote IP error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverURLFileName(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
ext string
|
||||
want string
|
||||
}{
|
||||
{raw: "https://example.com/images/cover", ext: ".png", want: "cover.png"},
|
||||
{raw: "https://example.com/images/cover.jpeg", ext: ".png", want: "cover.jpeg"},
|
||||
{raw: "https://example.com/", ext: ".webp", want: "cover.webp"},
|
||||
{raw: "https://example.com/images/%2Fescaped", ext: ".gif", want: "escaped.gif"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.want, func(t *testing.T) {
|
||||
u, err := url.Parse(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("url.Parse(%q): %v", tc.raw, err)
|
||||
}
|
||||
if got := docCoverURLFileName(u, tc.ext); got != tc.want {
|
||||
t.Fatalf("docCoverURLFileName() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadDocCoverURLSuccess(t *testing.T) {
|
||||
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write([]byte("png-data"))
|
||||
}))
|
||||
|
||||
content, fileName, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadDocCoverURL() error: %v", err)
|
||||
}
|
||||
if string(content) != "png-data" {
|
||||
t.Fatalf("content = %q, want png-data", string(content))
|
||||
}
|
||||
if fileName != "cover.png" {
|
||||
t.Fatalf("fileName = %q, want cover.png", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadDocCoverURLRejectsHTTPStatus(t *testing.T) {
|
||||
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte("unavailable"))
|
||||
}))
|
||||
|
||||
_, _, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
|
||||
if err == nil {
|
||||
t.Fatal("expected HTTP status error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed problem", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Fatalf("problem category = %v, want %v", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeNetworkServer {
|
||||
t.Fatalf("problem subtype = %v, want %v", p.Subtype, errs.SubtypeNetworkServer)
|
||||
}
|
||||
if p.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("problem code = %v, want %v", p.Code, http.StatusServiceUnavailable)
|
||||
}
|
||||
var networkErr *errs.NetworkError
|
||||
if !errors.As(err, &networkErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.NetworkError", err, err)
|
||||
}
|
||||
if networkErr.Cause == nil {
|
||||
t.Fatal("expected preserved underlying cause, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadDocCoverURLRejectsUnsupportedContentType(t *testing.T) {
|
||||
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("not-image"))
|
||||
}))
|
||||
|
||||
_, _, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
|
||||
}
|
||||
|
||||
func TestDownloadDocCoverURLRejectsOversizeResponse(t *testing.T) {
|
||||
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = io.CopyN(w, repeatByteReader('x'), docCoverURLMaxBytes+1)
|
||||
}))
|
||||
|
||||
_, _, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
|
||||
}
|
||||
|
||||
func TestDocCoverMetadataOutputAndOptionalFloats(t *testing.T) {
|
||||
x := 0.25
|
||||
y := 1.0
|
||||
out := docCoverMetadata{
|
||||
Token: "cover_token",
|
||||
OffsetRatioX: &x,
|
||||
OffsetRatioY: &y,
|
||||
}.toOutput()
|
||||
if out["token"] != "cover_token" || out["offset_ratio_x"] != x || out["offset_ratio_y"] != y {
|
||||
t.Fatalf("cover output = %#v", out)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"float": float64(1.5),
|
||||
"int": 2,
|
||||
"int64": int64(3),
|
||||
"text": "4",
|
||||
}
|
||||
if got, ok := getOptionalFloat(data, "float"); !ok || got != 1.5 {
|
||||
t.Fatalf("float optional = %v/%v, want 1.5/true", got, ok)
|
||||
}
|
||||
if got, ok := getOptionalFloat(data, "int"); !ok || got != 2 {
|
||||
t.Fatalf("int optional = %v/%v, want 2/true", got, ok)
|
||||
}
|
||||
if got, ok := getOptionalFloat(data, "int64"); !ok || got != 3 {
|
||||
t.Fatalf("int64 optional = %v/%v, want 3/true", got, ok)
|
||||
}
|
||||
if _, ok := getOptionalFloat(data, "text"); ok {
|
||||
t.Fatal("string optional unexpectedly parsed as float")
|
||||
}
|
||||
if _, ok := getOptionalFloat(nil, "missing"); ok {
|
||||
t.Fatal("nil map optional unexpectedly returned a value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverDryRunSource(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
str map[string]string
|
||||
bools map[string]bool
|
||||
want string
|
||||
}{
|
||||
{name: "clipboard", bools: map[string]bool{"from-clipboard": true}, want: "<clipboard image>"},
|
||||
{name: "url", str: map[string]string{"url": "https://example.com/cover.png"}, want: "https://example.com/cover.png"},
|
||||
{name: "file", str: map[string]string{"file": "cover.png"}, want: "@cover.png"},
|
||||
{name: "empty", want: "<cover image>"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rt := docValidateRuntime(t, tc.str, tc.bools, nil)
|
||||
if got := docCoverDryRunSource(rt); got != tc.want {
|
||||
t.Fatalf("docCoverDryRunSource() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocShortcutsIncludeCoverResourceCommands(t *testing.T) {
|
||||
got := map[string]bool{}
|
||||
for _, shortcut := range Shortcuts() {
|
||||
got[shortcut.Command] = true
|
||||
}
|
||||
for _, want := range []string{"resource-download", "resource-update", "resource-delete"} {
|
||||
if !got[want] {
|
||||
t.Fatalf("Shortcuts() missing %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newDocCoverURLTestRuntime(t *testing.T, handler http.Handler) (*common.RuntimeContext, string) {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewTLSServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-url-download-app"))
|
||||
targetAddr := server.Listener.Addr().String()
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, network, targetAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return docCoverRemoteAddrConn{
|
||||
Conn: conn,
|
||||
addr: &net.TCPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
return &common.RuntimeContext{Factory: f}, "https://1.1.1.1/assets/cover"
|
||||
}
|
||||
|
||||
type docCoverRemoteAddrConn struct {
|
||||
net.Conn
|
||||
addr net.Addr
|
||||
}
|
||||
|
||||
func (c docCoverRemoteAddrConn) RemoteAddr() net.Addr {
|
||||
return c.addr
|
||||
}
|
||||
|
||||
type testAddr string
|
||||
|
||||
func (a testAddr) Network() string {
|
||||
return "test"
|
||||
}
|
||||
|
||||
func (a testAddr) String() string {
|
||||
return string(a)
|
||||
}
|
||||
|
||||
type repeatByteReader byte
|
||||
|
||||
func (r repeatByteReader) Read(p []byte) (int, error) {
|
||||
for i := range p {
|
||||
p[i] = byte(r)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func docCoverMetadataStub(documentID string, cover map[string]interface{}) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docx/v1/documents/" + documentID,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"cover": cover,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type docResourceOutput struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeDocResourceOutput(t *testing.T, stdout *bytes.Buffer) docResourceOutput {
|
||||
t.Helper()
|
||||
var out docResourceOutput
|
||||
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode resource output: %v; output=%s", err, stdout.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -19,6 +19,7 @@ func v2FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
||||
{Name: "lang", Desc: "user cite display language, e.g. en-US, zh-CN, ja-JP"},
|
||||
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||
{Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
|
||||
@@ -38,9 +39,6 @@ func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc")
|
||||
}
|
||||
if err := validateFetchDetail(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateReadModeFlags(runtime); err != nil {
|
||||
@@ -71,6 +69,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
|
||||
}
|
||||
|
||||
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
|
||||
if doc, ok := data["document"].(map[string]interface{}); ok {
|
||||
@@ -89,8 +90,11 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
if v := runtime.Int("revision-id"); v > 0 {
|
||||
body["revision_id"] = v
|
||||
}
|
||||
if lang := resolveFetchLang(runtime); lang != "" {
|
||||
body["lang"] = lang
|
||||
}
|
||||
|
||||
detail := runtime.Str("detail")
|
||||
detail := effectiveFetchDetail(runtime)
|
||||
switch detail {
|
||||
case "", "simple":
|
||||
body["export_option"] = map[string]interface{}{
|
||||
@@ -118,6 +122,16 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return body
|
||||
}
|
||||
|
||||
func resolveFetchLang(runtime *common.RuntimeContext) string {
|
||||
if runtime.Changed("lang") {
|
||||
return strings.TrimSpace(runtime.Str("lang"))
|
||||
}
|
||||
if runtime.Config == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(runtime.Config.Lang))
|
||||
}
|
||||
|
||||
// buildReadOption 拼装 read_option JSON;full/空模式返回 nil,让服务端走默认全文路径。
|
||||
func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
mode := strings.TrimSpace(runtime.Str("scope"))
|
||||
@@ -146,17 +160,33 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return ro
|
||||
}
|
||||
|
||||
// validateFetchDetail 非 xml 格式(markdown)不承载 block_id 与样式属性,拒绝 with-ids/full。
|
||||
func validateFetchDetail(runtime *common.RuntimeContext) error {
|
||||
// effectiveFetchDetail degrades detail options that cannot be represented by
|
||||
// non-XML exports. The original flag value is left intact so callers can still
|
||||
// surface an explicit warning in execute output.
|
||||
func effectiveFetchDetail(runtime *common.RuntimeContext) string {
|
||||
format := strings.TrimSpace(runtime.Str("doc-format"))
|
||||
detail := strings.TrimSpace(runtime.Str("detail"))
|
||||
if format == "" || format == "xml" {
|
||||
return nil
|
||||
return detail
|
||||
}
|
||||
if detail == "with-ids" || detail == "full" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format).WithParam("--detail")
|
||||
return "simple"
|
||||
}
|
||||
return nil
|
||||
return detail
|
||||
}
|
||||
|
||||
func addFetchDetailDowngradeWarning(runtime *common.RuntimeContext, data map[string]interface{}) string {
|
||||
format := strings.TrimSpace(runtime.Str("doc-format"))
|
||||
detail := strings.TrimSpace(runtime.Str("detail"))
|
||||
if format == "" || format == "xml" {
|
||||
return ""
|
||||
}
|
||||
if detail != "with-ids" && detail != "full" {
|
||||
return ""
|
||||
}
|
||||
warning := fmt.Sprintf("--detail %s is only supported with --doc-format xml; returning %s output and ignoring the unsupported detail option", detail, format)
|
||||
appendDocWarning(data, warning)
|
||||
return warning
|
||||
}
|
||||
|
||||
// validateReadModeFlags 客户端前置校验,服务端也会再校验一次。
|
||||
|
||||
@@ -5,9 +5,13 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -59,6 +63,47 @@ func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesExplicitLang(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
if err := runtime.Cmd.Flags().Set("lang", "en-US"); err != nil {
|
||||
t.Fatalf("set lang: %v", err)
|
||||
}
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if got := body["lang"]; got != "en-US" {
|
||||
t.Fatalf("lang = %#v, want %q", got, "en-US")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyUsesRuntimeConfigLang(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
runtime.Config = &core.CliConfig{Lang: "zh_cn"}
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if got := body["lang"]; got != "zh_cn" {
|
||||
t.Fatalf("lang = %#v, want %q", got, "zh_cn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyExplicitBlankLangOmitsLang(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
runtime.Config = &core.CliConfig{Lang: "zh_cn"}
|
||||
if err := runtime.Cmd.Flags().Set("lang", ""); err != nil {
|
||||
t.Fatalf("set lang: %v", err)
|
||||
}
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if _, ok := body["lang"]; ok {
|
||||
t.Fatalf("did not expect blank explicit lang in fetch body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -96,6 +141,126 @@ func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, detail := range []string{"with-ids", "full"} {
|
||||
t.Run(detail, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
|
||||
"doc-format": "markdown",
|
||||
"detail": detail,
|
||||
})
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
|
||||
if exportOption == nil {
|
||||
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
|
||||
}
|
||||
if got := exportOption["export_block_id"]; got != false {
|
||||
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_style_attrs"]; got != false {
|
||||
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_cite_extra_data"]; got != false {
|
||||
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradeWarnsInOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-warning"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchWarning/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnFetchWarning",
|
||||
"revision_id": float64(1),
|
||||
"content": "# hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchWarning",
|
||||
"--doc-format", "markdown",
|
||||
"--detail", "with-ids",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
warnings, _ := data["warnings"].([]interface{})
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("warnings = %#v, want one downgrade warning", data["warnings"])
|
||||
}
|
||||
if got, _ := warnings[0].(string); !strings.Contains(got, "returning markdown output") || !strings.Contains(got, "ignoring the unsupported detail option") {
|
||||
t.Fatalf("unexpected warning: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-pretty-warning"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchPrettyWarning/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnFetchPrettyWarning",
|
||||
"revision_id": float64(1),
|
||||
"content": "# hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchPrettyWarning",
|
||||
"--doc-format", "markdown",
|
||||
"--detail", "full",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); got != "# hello\n" {
|
||||
t.Fatalf("stdout = %q, want markdown content only", got)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: --detail full is only supported with --doc-format xml") ||
|
||||
!strings.Contains(got, "returning markdown output") ||
|
||||
!strings.Contains(got, "ignoring the unsupported detail option") {
|
||||
t.Fatalf("stderr missing downgrade warning: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -139,6 +304,7 @@ func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+fetch"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("detail", "simple", "")
|
||||
cmd.Flags().String("lang", "", "")
|
||||
cmd.Flags().Int("revision-id", -1, "")
|
||||
cmd.Flags().String("scope", "full", "")
|
||||
cmd.Flags().String("start-block-id", "", "")
|
||||
@@ -158,6 +324,7 @@ func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[s
|
||||
cmd.Flags().String("doc", "doxcnFetchDryRun", "")
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("detail", "simple", "")
|
||||
cmd.Flags().String("lang", "", "")
|
||||
cmd.Flags().Int("revision-id", -1, "")
|
||||
cmd.Flags().String("scope", "full", "")
|
||||
cmd.Flags().String("start-block-id", "", "")
|
||||
|
||||
@@ -91,3 +91,22 @@ func buildDriveRouteExtra(docID string) (string, error) {
|
||||
}
|
||||
return string(extra), nil
|
||||
}
|
||||
|
||||
func appendDocWarning(data map[string]interface{}, warning string) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(warning) == "" {
|
||||
return
|
||||
}
|
||||
switch existing := data["warnings"].(type) {
|
||||
case []interface{}:
|
||||
data["warnings"] = append(existing, warning)
|
||||
case []string:
|
||||
data["warnings"] = append(existing, warning)
|
||||
case nil:
|
||||
data["warnings"] = []string{warning}
|
||||
default:
|
||||
data["warnings"] = []interface{}{existing, warning}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -88,3 +89,51 @@ func TestBuildDriveRouteExtraEscapesJSON(t *testing.T) {
|
||||
t.Fatalf("buildDriveRouteExtra() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDocWarning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
appendDocWarning(nil, "ignored")
|
||||
|
||||
empty := map[string]interface{}{}
|
||||
appendDocWarning(empty, " ")
|
||||
if _, ok := empty["warnings"]; ok {
|
||||
t.Fatalf("blank warning should be ignored: %#v", empty)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]interface{}
|
||||
want interface{}
|
||||
}{
|
||||
{
|
||||
name: "missing warnings",
|
||||
data: map[string]interface{}{},
|
||||
want: []string{"new warning"},
|
||||
},
|
||||
{
|
||||
name: "string slice warnings",
|
||||
data: map[string]interface{}{"warnings": []string{"old warning"}},
|
||||
want: []string{"old warning", "new warning"},
|
||||
},
|
||||
{
|
||||
name: "interface slice warnings",
|
||||
data: map[string]interface{}{"warnings": []interface{}{"old warning"}},
|
||||
want: []interface{}{"old warning", "new warning"},
|
||||
},
|
||||
{
|
||||
name: "scalar warning",
|
||||
data: map[string]interface{}{"warnings": "old warning"},
|
||||
want: []interface{}{"old warning", "new warning"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
appendDocWarning(tt.data, "new warning")
|
||||
if got := tt.data["warnings"]; !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("warnings = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ func Shortcuts() []common.Shortcut {
|
||||
DocMediaUpload,
|
||||
DocMediaPreview,
|
||||
DocMediaDownload,
|
||||
DocResourceDownload,
|
||||
DocResourceUpdate,
|
||||
DocResourceDelete,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,301 +34,242 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
|
||||
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
|
||||
{Name: "only-schema", Type: "bool", Desc: "export only bitable schema when --doc-type bitable --file-extension base"},
|
||||
{Name: "file-name", Desc: "preferred output filename (optional)"},
|
||||
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return ValidateExport(exportParamsFromFlags(runtime))
|
||||
return validateDriveExportSpec(driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
|
||||
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("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 != "" {
|
||||
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 != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
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"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// 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)
|
||||
// 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 {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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):
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
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
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.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 = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), 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)
|
||||
return err
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
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):
|
||||
}
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
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())
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ type driveExportSpec struct {
|
||||
DocType string
|
||||
FileExtension string
|
||||
SubID string
|
||||
OnlySchema bool
|
||||
}
|
||||
|
||||
// driveExportTaskResultCommand prints the resume command shown when bounded
|
||||
@@ -150,6 +151,10 @@ func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable")
|
||||
}
|
||||
|
||||
if spec.OnlySchema && (spec.DocType != "bitable" || spec.FileExtension != "base") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").WithParam("--only-schema")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides")
|
||||
}
|
||||
@@ -185,6 +190,9 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
if err != nil {
|
||||
|
||||
@@ -51,6 +51,15 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
name: "base bitable ok",
|
||||
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "base"},
|
||||
},
|
||||
{
|
||||
name: "base bitable only schema ok",
|
||||
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "base", OnlySchema: true},
|
||||
},
|
||||
{
|
||||
name: "only schema non base rejected",
|
||||
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "xlsx", OnlySchema: true},
|
||||
wantErr: "--only-schema is only used",
|
||||
},
|
||||
{
|
||||
name: "slides pptx ok",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "pptx"},
|
||||
@@ -488,72 +497,6 @@ 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{
|
||||
@@ -678,6 +621,7 @@ func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
|
||||
"--token", "bitable123",
|
||||
"--doc-type", "bitable",
|
||||
"--file-extension", "base",
|
||||
"--only-schema",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
@@ -694,6 +638,9 @@ func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
|
||||
if createBody["type"] != "bitable" {
|
||||
t.Fatalf("export_tasks body type = %v, want %q", createBody["type"], "bitable")
|
||||
}
|
||||
if createBody["only_schema"] != true {
|
||||
t.Fatalf("export_tasks body only_schema = %v, want true", createBody["only_schema"])
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "crm.base"))
|
||||
if err != nil {
|
||||
|
||||
@@ -25,7 +25,8 @@ var DriveImport = common.Shortcut{
|
||||
"docs:document.media:upload",
|
||||
"docs:document:import",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx; large files auto use multipart upload; .base is capped at 20MB, .pptx at 500MB)", Required: true},
|
||||
{Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true},
|
||||
@@ -34,160 +35,132 @@ 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 ValidateImport(importParamsFromFlags(runtime))
|
||||
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"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
|
||||
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")
|
||||
|
||||
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
|
||||
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
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
|
||||
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
|
||||
}
|
||||
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); 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
|
||||
},
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -257,6 +257,46 @@ func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendDriveImportFolderTokenWikiCheckDryRun(dry *common.DryRunAPI, spec driveImportSpec) {
|
||||
folderToken := strings.TrimSpace(spec.FolderToken)
|
||||
if folderToken == "" {
|
||||
return
|
||||
}
|
||||
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[0] Validate whether --folder-token is a wiki node").
|
||||
Params(map[string]interface{}{"token": folderToken})
|
||||
}
|
||||
|
||||
func rejectDriveImportWikiFolderToken(runtime *common.RuntimeContext, folderToken string) error {
|
||||
folderToken = strings.TrimSpace(folderToken)
|
||||
if folderToken == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": folderToken},
|
||||
nil,
|
||||
)
|
||||
if err == nil {
|
||||
node := common.GetMap(data, "node")
|
||||
if len(node) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--folder-token only supports Drive folder tokens, but the provided token resolves to a wiki node",
|
||||
).
|
||||
WithParam("--folder-token").
|
||||
WithHint("Pass a Drive folder token, or omit --folder-token to import into the Drive root folder. Wiki node tokens are not accepted as import mount folders.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// driveImportStatus captures the backend fields needed to decide whether the
|
||||
// import can be surfaced immediately or requires a follow-up poll.
|
||||
type driveImportStatus struct {
|
||||
|
||||
@@ -5,10 +5,12 @@ package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
@@ -276,6 +278,134 @@ func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportRejectsWikiFolderToken(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"node_token": "wikcnImportTarget",
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxImportTarget",
|
||||
"title": "Wiki Import Target",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("notes.md", []byte("# Hi"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "notes.md",
|
||||
"--type", "docx",
|
||||
"--folder-token", "wikcnImportTarget",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected wiki folder-token validation 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.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if validationErr.Param != "--folder-token" {
|
||||
t.Fatalf("param = %q, want --folder-token", validationErr.Param)
|
||||
}
|
||||
wantMessage := "--folder-token only supports Drive folder tokens, but the provided token resolves to a wiki node"
|
||||
if validationErr.Message != wantMessage {
|
||||
t.Fatalf("message = %q, want %q", validationErr.Message, wantMessage)
|
||||
}
|
||||
for _, disallowed := range []string{"node_token=", "obj_type=", "Wiki Import Target"} {
|
||||
if strings.Contains(validationErr.Message, disallowed) {
|
||||
t.Fatalf("message = %q, must not contain %q", validationErr.Message, disallowed)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "Drive folder token") {
|
||||
t.Fatalf("hint = %q, want Drive folder token guidance", validationErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportContinuesWhenFolderTokenDoesNotResolveAsWiki(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1310001,
|
||||
"msg": "node not found",
|
||||
},
|
||||
})
|
||||
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_import_media"},
|
||||
},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/import_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_import_folder"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/tk_import_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"type": "docx",
|
||||
"job_status": 0,
|
||||
"token": "docx_imported",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("notes.md", []byte("# Hi"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "notes.md",
|
||||
"--type", "docx",
|
||||
"--folder-token", "fldcnImportTarget",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got := data["token"]; got != "docx_imported" {
|
||||
t.Fatalf("token = %#v, want docx_imported", got)
|
||||
}
|
||||
body := decodeCapturedJSONBody(t, createStub)
|
||||
point, _ := body["point"].(map[string]interface{})
|
||||
if got := point["mount_key"]; got != "fldcnImportTarget" {
|
||||
t.Fatalf("import mount_key = %#v, want fldcnImportTarget", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
|
||||
@@ -114,16 +114,20 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 3 {
|
||||
t.Fatalf("expected 3 API calls, got %d", len(got.API))
|
||||
if len(got.API) != 4 {
|
||||
t.Fatalf("expected 4 API calls, got %d", len(got.API))
|
||||
}
|
||||
|
||||
uploadName, _ := got.API[0].Body["file_name"].(string)
|
||||
if got.API[0].Body != nil {
|
||||
t.Fatalf("wiki probe should not have a request body, got %#v", got.API[0].Body)
|
||||
}
|
||||
|
||||
uploadName, _ := got.API[1].Body["file_name"].(string)
|
||||
if uploadName != "base-import.xlsx" {
|
||||
t.Fatalf("upload file_name = %q, want %q", uploadName, "base-import.xlsx")
|
||||
}
|
||||
|
||||
importName, _ := got.API[1].Body["file_name"].(string)
|
||||
importName, _ := got.API[2].Body["file_name"].(string)
|
||||
if importName != "base-import" {
|
||||
t.Fatalf("import task file_name = %q, want %q", importName, "base-import")
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ import (
|
||||
)
|
||||
|
||||
// driveSearchErrUserNotVisible is the Lark service code returned by
|
||||
// doc_wiki/search when an open_id referenced in --creator-ids / --sharer-ids
|
||||
// falls outside the app's user-visibility scope (different from the
|
||||
// search:docs:read API scope).
|
||||
// doc_wiki/search when an open_id referenced in an identity filter falls
|
||||
// outside the app's user-visibility scope (different from the search:docs:read
|
||||
// API scope).
|
||||
const driveSearchErrUserNotVisible = 99992351
|
||||
|
||||
// open_time has a server-side cap of 3 months per request. Rather than
|
||||
@@ -79,6 +79,8 @@ var DriveSearch = common.Shortcut{
|
||||
|
||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
||||
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
||||
{Name: "created-by-me", Type: "bool", Desc: "restrict to docs originally created by me (uses current user's open_id as original_creator_ids)"},
|
||||
{Name: "original-creator-ids", Desc: "comma-separated original creator open_ids; mutually exclusive with --created-by-me"},
|
||||
|
||||
{Name: "edited-since", Desc: "start of [my edited] time window (e.g. 7d, 1m, 1y, 2026-04-01, RFC3339, unix seconds)"},
|
||||
{Name: "edited-until", Desc: "end of [my edited] time window"},
|
||||
@@ -108,7 +110,7 @@ var DriveSearch = common.Shortcut{
|
||||
Tips: []string{
|
||||
"Time flags accept relative (e.g. 7d, 1m, 1y), absolute (2026-04-01, RFC3339), or unix seconds.",
|
||||
"my_edit_time and my_comment_time are hour-aggregated server-side; sub-hour inputs are snapped and a notice is printed to stderr.",
|
||||
"Use --mine for a quick \"docs I own\" filter (owner semantic, not original creator). For other people, use --creator-ids ou_xxx,ou_yyy.",
|
||||
"Use --created-by-me for \"docs I created\". Use --mine for \"docs I own\" (owner semantic).",
|
||||
"--folder-tokens limits to doc-only search; --space-ids limits to wiki-only. They cannot be combined.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -164,6 +166,9 @@ type driveSearchSpec struct {
|
||||
Mine bool
|
||||
CreatorIDs []string
|
||||
|
||||
CreatedByMe bool
|
||||
OriginalCreatorIDs []string
|
||||
|
||||
EditedSince string
|
||||
EditedUntil string
|
||||
CommentedSince string
|
||||
@@ -193,6 +198,9 @@ func readDriveSearchSpec(runtime *common.RuntimeContext) driveSearchSpec {
|
||||
Mine: runtime.Bool("mine"),
|
||||
CreatorIDs: common.SplitCSV(runtime.Str("creator-ids")),
|
||||
|
||||
CreatedByMe: runtime.Bool("created-by-me"),
|
||||
OriginalCreatorIDs: common.SplitCSV(runtime.Str("original-creator-ids")),
|
||||
|
||||
EditedSince: runtime.Str("edited-since"),
|
||||
EditedUntil: runtime.Str("edited-until"),
|
||||
CommentedSince: runtime.Str("commented-since"),
|
||||
@@ -221,12 +229,18 @@ func buildDriveSearchRequest(spec driveSearchSpec, userOpenID string, now time.T
|
||||
if spec.Mine && len(spec.CreatorIDs) > 0 {
|
||||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --mine and --creator-ids")
|
||||
}
|
||||
if spec.CreatedByMe && len(spec.OriginalCreatorIDs) > 0 {
|
||||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --created-by-me and --original-creator-ids")
|
||||
}
|
||||
if len(spec.FolderTokens) > 0 && len(spec.SpaceIDs) > 0 {
|
||||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined")
|
||||
}
|
||||
if spec.Mine && userOpenID == "" {
|
||||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--mine requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config").WithParam("--mine")
|
||||
}
|
||||
if spec.CreatedByMe && userOpenID == "" {
|
||||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--created-by-me requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config").WithParam("--created-by-me")
|
||||
}
|
||||
|
||||
if err := validateDocTypes(spec.DocTypes); err != nil {
|
||||
return nil, nil, err
|
||||
@@ -256,13 +270,20 @@ func buildDriveSearchRequest(spec driveSearchSpec, userOpenID string, now time.T
|
||||
notices = append(notices, n)
|
||||
}
|
||||
|
||||
// Creator identity.
|
||||
// Identity filters. creator_ids is owner; original_creator_ids is the
|
||||
// immutable document creator.
|
||||
switch {
|
||||
case spec.Mine:
|
||||
filter["creator_ids"] = []string{userOpenID}
|
||||
case len(spec.CreatorIDs) > 0:
|
||||
filter["creator_ids"] = spec.CreatorIDs
|
||||
}
|
||||
switch {
|
||||
case spec.CreatedByMe:
|
||||
filter["original_creator_ids"] = []string{userOpenID}
|
||||
case len(spec.OriginalCreatorIDs) > 0:
|
||||
filter["original_creator_ids"] = spec.OriginalCreatorIDs
|
||||
}
|
||||
|
||||
// Time dimensions — each fills at most one filter key; hour-aggregated ones
|
||||
// also contribute notices.
|
||||
@@ -358,6 +379,11 @@ func validateDriveSearchIDs(spec driveSearchSpec) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--creator-ids %q: %s", id, err).WithParam("--creator-ids")
|
||||
}
|
||||
}
|
||||
for _, id := range spec.OriginalCreatorIDs {
|
||||
if _, err := common.ValidateUserIDTyped("--original-creator-ids", id); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--original-creator-ids %q: %s", id, err).WithParam("--original-creator-ids")
|
||||
}
|
||||
}
|
||||
if n := len(spec.ChatIDs); n > driveSearchMaxChatIDs {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n).WithParam("--chat-ids")
|
||||
}
|
||||
@@ -635,7 +661,7 @@ func enrichDriveSearchError(err error) error {
|
||||
if !ok || p.Code != driveSearchErrUserNotVisible {
|
||||
return err
|
||||
}
|
||||
p.Hint = "one or more open_ids in --creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids"
|
||||
p.Hint = "one or more open_ids in --creator-ids / --original-creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids"
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -245,9 +245,10 @@ func TestValidateDriveSearchIDs(t *testing.T) {
|
||||
t.Run("all valid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
spec := driveSearchSpec{
|
||||
CreatorIDs: []string{"ou_aaa"},
|
||||
ChatIDs: []string{"oc_xxx"},
|
||||
SharerIDs: []string{"ou_bbb"},
|
||||
CreatorIDs: []string{"ou_aaa"},
|
||||
OriginalCreatorIDs: []string{"ou_ccc"},
|
||||
ChatIDs: []string{"oc_xxx"},
|
||||
SharerIDs: []string{"ou_bbb"},
|
||||
}
|
||||
if err := validateDriveSearchIDs(spec); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -275,6 +276,24 @@ func TestValidateDriveSearchIDs(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bad original creator id format", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateDriveSearchIDs(driveSearchSpec{OriginalCreatorIDs: []string{"u_bad"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "--original-creator-ids") {
|
||||
t.Fatalf("expected --original-creator-ids error, got: %v", err)
|
||||
}
|
||||
var vErr *errs.ValidationError
|
||||
if !errors.As(err, &vErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if vErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if vErr.Param != "--original-creator-ids" {
|
||||
t.Fatalf("Param = %q, want --original-creator-ids", vErr.Param)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bad chat id format", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateDriveSearchIDs(driveSearchSpec{ChatIDs: []string{"chat_bad"}})
|
||||
@@ -727,6 +746,33 @@ func TestBuildDriveSearchRequest(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--created-by-me fills original_creator_ids from userOpenID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req, _, err := buildDriveSearchRequest(driveSearchSpec{CreatedByMe: true}, userOpenID, now)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
got := req["doc_filter"].(map[string]interface{})["original_creator_ids"].([]string)
|
||||
if len(got) != 1 || got[0] != userOpenID {
|
||||
t.Fatalf("expected [userOpenID], got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--original-creator-ids fills original_creator_ids", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
spec := driveSearchSpec{OriginalCreatorIDs: []string{"ou_a", "ou_b"}}
|
||||
req, _, err := buildDriveSearchRequest(spec, userOpenID, now)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
for _, filterKey := range []string{"doc_filter", "wiki_filter"} {
|
||||
got := req[filterKey].(map[string]interface{})["original_creator_ids"].([]string)
|
||||
if !reflect.DeepEqual(got, []string{"ou_a", "ou_b"}) {
|
||||
t.Fatalf("%s: expected explicit original creator ids, got %v", filterKey, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--mine without userOpenID errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := buildDriveSearchRequest(driveSearchSpec{Mine: true}, "", now)
|
||||
@@ -735,6 +781,14 @@ func TestBuildDriveSearchRequest(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--created-by-me without userOpenID errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := buildDriveSearchRequest(driveSearchSpec{CreatedByMe: true}, "", now)
|
||||
if err == nil || !strings.Contains(err.Error(), "--created-by-me") {
|
||||
t.Fatalf("expected --created-by-me error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--mine + --creator-ids mutually exclusive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
spec := driveSearchSpec{Mine: true, CreatorIDs: []string{"ou_x"}}
|
||||
@@ -756,6 +810,15 @@ func TestBuildDriveSearchRequest(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--created-by-me + --original-creator-ids mutually exclusive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
spec := driveSearchSpec{CreatedByMe: true, OriginalCreatorIDs: []string{"ou_x"}}
|
||||
_, _, err := buildDriveSearchRequest(spec, userOpenID, now)
|
||||
if err == nil || !strings.Contains(err.Error(), "--created-by-me") {
|
||||
t.Fatalf("expected exclusion error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--folder-tokens + --space-ids mutually exclusive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
spec := driveSearchSpec{
|
||||
|
||||
@@ -123,6 +123,13 @@ func isHTMLBlockBoundary(n *xhtml.Node) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// PlainTextFromHTML is the exported wrapper over plainTextFromHTML, so the
|
||||
// mail package can render an HTML signature as a plain-text fallback when a
|
||||
// message body is sent in plain-text mode. The conversion logic is unchanged.
|
||||
func PlainTextFromHTML(raw string) string {
|
||||
return plainTextFromHTML(raw)
|
||||
}
|
||||
|
||||
// bodyLooksLikeHTML reports whether raw appears to contain HTML markup.
|
||||
// This is intentionally heuristic: it exists to reject obvious plain-text
|
||||
// input when a draft's authored body is text/html.
|
||||
|
||||
@@ -102,3 +102,10 @@ func TestIsHTMLNonTextTag(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainTextFromHTMLExported(t *testing.T) {
|
||||
got := PlainTextFromHTML("<p>Hello world</p>")
|
||||
if !strings.Contains(got, "Hello world") {
|
||||
t.Fatalf("PlainTextFromHTML: expected to contain \"Hello world\", got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ var MailDraftCreate = common.Shortcut{
|
||||
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
|
||||
signatureFlag,
|
||||
noSignatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
showLintDetailsFlag,
|
||||
@@ -92,7 +93,7 @@ var MailDraftCreate = common.Shortcut{
|
||||
if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
|
||||
return mailValidationParamError("--subject", "--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 {
|
||||
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventFlags(runtime); err != nil {
|
||||
@@ -180,12 +181,22 @@ var MailDraftCreate = common.Shortcut{
|
||||
if strings.TrimSpace(input.Body) == "" {
|
||||
return mailValidationParamError("--body", "effective body is empty after applying template; pass --body explicitly")
|
||||
}
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
|
||||
signatureID := runtime.Str("signature-id")
|
||||
noSignature := runtime.Bool("no-signature")
|
||||
senderEmail := resolveComposeSenderEmail(runtime)
|
||||
// Auto-resolve default signature when neither --no-signature nor --signature-id is set.
|
||||
if noSignature {
|
||||
signatureID = ""
|
||||
} else if signatureID == "" {
|
||||
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, false)
|
||||
}
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
|
||||
runtime.Str("signature-id") != "", !input.PlainText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawEML, lintApplied, lintBlocked, err := buildRawEMLForDraftCreate(ctx, runtime, input, sigResult, priority,
|
||||
templateLargeAttachmentIDs, mailboxID, templateID, templateInlineAttachments, templateSmallAttachments)
|
||||
templateLargeAttachmentIDs, mailboxID, templateID, templateInlineAttachments, templateSmallAttachments, senderEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -241,13 +252,19 @@ func buildRawEMLForDraftCreate(
|
||||
mailboxID, templateID string,
|
||||
templateInlineAttachments []templateInlineRef,
|
||||
templateSmallAttachments []templateAttachmentRef,
|
||||
senderEmailHint string,
|
||||
) (rawEMLOut string, lintApplied, lintBlocked []lint.Finding, err error) {
|
||||
// Initialise lint findings as empty (non-nil) slices so callers can
|
||||
// surface them through the envelope unconditionally even on the
|
||||
// plain-text branch.
|
||||
lintApplied, lintBlocked = emptyLintFindings()
|
||||
|
||||
senderEmail := resolveComposeSenderEmail(runtime)
|
||||
// Use the pre-resolved senderEmail when available (avoids a duplicate
|
||||
// profile API call when Execute already fetched it for auto-resolve).
|
||||
senderEmail := senderEmailHint
|
||||
if senderEmail == "" {
|
||||
senderEmail = resolveComposeSenderEmail(runtime)
|
||||
}
|
||||
if senderEmail == "" {
|
||||
return "", lintApplied, lintBlocked, mailValidationParamError("--from", "unable to determine sender email; please specify --from explicitly")
|
||||
}
|
||||
@@ -290,7 +307,7 @@ func buildRawEMLForDraftCreate(
|
||||
var composedHTMLBody string
|
||||
var composedTextBody string
|
||||
if input.PlainText {
|
||||
composedTextBody = input.Body
|
||||
composedTextBody = injectPlainTextSignature(input.Body, sigResult)
|
||||
bld = bld.TextBody([]byte(composedTextBody))
|
||||
} else if bodyIsHTML(input.Body) || sigResult != nil {
|
||||
htmlBody := input.Body
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) {
|
||||
Body: `<p>Hello</p><p><img src="./test_image.png" /></p>`,
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) {
|
||||
Body: `<p>Hello <b>world</b></p>`,
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) {
|
||||
Attach: "./big.txt",
|
||||
}
|
||||
|
||||
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB")
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) {
|
||||
Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`,
|
||||
}
|
||||
|
||||
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for orphaned --inline CID not referenced in body")
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
|
||||
Inline: `[{"cid":"present","file_path":"./present.png"}]`,
|
||||
}
|
||||
|
||||
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing CID reference")
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func TestBuildRawEMLForDraftCreate_WithPriority(t *testing.T) {
|
||||
Body: `<p>Hello</p>`,
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "1", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "1", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func TestBuildRawEMLForDraftCreate_NoPriority(t *testing.T) {
|
||||
Body: `<p>Hello</p>`,
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -237,7 +237,7 @@ func TestBuildRawEMLForDraftCreate_RequestReceiptAddsHeader(t *testing.T) {
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(),
|
||||
newRuntimeWithFromAndRequestReceipt("sender@example.com", true), input, nil, "", nil, "", "", nil, nil)
|
||||
newRuntimeWithFromAndRequestReceipt("sender@example.com", true), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -260,7 +260,7 @@ func TestBuildRawEMLForDraftCreate_RequestReceiptOmittedByDefault(t *testing.T)
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(),
|
||||
newRuntimeWithFromAndRequestReceipt("sender@example.com", false), input, nil, "", nil, "", "", nil, nil)
|
||||
newRuntimeWithFromAndRequestReceipt("sender@example.com", false), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -283,7 +283,7 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
|
||||
PlainText: true,
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -304,7 +304,7 @@ func TestBuildRawEMLForDraftCreate_WithCalendarEvent(t *testing.T) {
|
||||
Body: "<p>Please join us</p>",
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), rt, input, nil, "", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), rt, input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ var MailDraftEdit = common.Shortcut{
|
||||
for i := range patch.Ops {
|
||||
switch patch.Ops[i].Op {
|
||||
case "insert_signature":
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail)
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail, true, true)
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ var MailForward = common.Shortcut{
|
||||
{Name: "subject", Desc: "Optional. Override the auto-generated Fw: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are merged into the forward draft (template values appended to user flags / forward-derived values; no de-duplication)."},
|
||||
signatureFlag,
|
||||
noSignatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
showLintDetailsFlag},
|
||||
@@ -96,7 +97,7 @@ var MailForward = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventFlags(runtime); err != nil {
|
||||
@@ -127,12 +128,7 @@ var MailForward = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
signatureID := runtime.Str("signature-id")
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to fetch original message")
|
||||
@@ -156,6 +152,18 @@ var MailForward = common.Shortcut{
|
||||
senderEmail = orig.headTo
|
||||
}
|
||||
|
||||
// Signature ID is resolved here (after senderEmail is finalised) so DefaultReplyID
|
||||
// matches the correct usage. The actual image download in resolveSignature is deferred
|
||||
// to after applyTemplate so the final plainText value (which a template can override
|
||||
// via IsPlainTextMode) is used for the downloadImages decision.
|
||||
signatureID := runtime.Str("signature-id")
|
||||
noSignature := runtime.Bool("no-signature")
|
||||
if noSignature {
|
||||
signatureID = ""
|
||||
} else if signatureID == "" {
|
||||
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, true /*isReply*/)
|
||||
}
|
||||
|
||||
// --template-id merge (§5.5 Q1-Q5).
|
||||
var templateLargeAttachmentIDs []string
|
||||
var templateInlineAttachments []templateInlineRef
|
||||
@@ -198,6 +206,14 @@ var MailForward = common.Shortcut{
|
||||
"bccs_count": countAddresses(bccFlag),
|
||||
})
|
||||
}
|
||||
// Resolve signature after template processing so plainText reflects any IsPlainTextMode
|
||||
// override from the template. This avoids downloading HTML signature images when the
|
||||
// template forces plain-text mode, which could cause CDN 403/5xx or timeout errors.
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
|
||||
runtime.Str("signature-id") != "", !plainText)
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
subjectOverride := strings.TrimSpace(runtime.Str("subject"))
|
||||
|
||||
// Post-merge recipient check for --confirm-send + --template-id:
|
||||
@@ -310,7 +326,7 @@ var MailForward = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
composedTextBody = buildForwardedMessage(&orig, body)
|
||||
composedTextBody = buildForwardedMessage(&orig, injectPlainTextSignature(body, sigResult))
|
||||
bld = bld.TextBody([]byte(composedTextBody))
|
||||
}
|
||||
// Embed template SMALL non-inline attachments regardless of body mode.
|
||||
|
||||
@@ -42,6 +42,7 @@ var MailReply = common.Shortcut{
|
||||
{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
|
||||
signatureFlag,
|
||||
noSignatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
showLintDetailsFlag},
|
||||
@@ -93,7 +94,7 @@ var MailReply = common.Shortcut{
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventFlags(runtime); err != nil {
|
||||
@@ -129,12 +130,7 @@ var MailReply = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
signatureID := runtime.Str("signature-id")
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to fetch original message")
|
||||
@@ -156,6 +152,18 @@ var MailReply = common.Shortcut{
|
||||
senderEmail = orig.headTo
|
||||
}
|
||||
|
||||
// Signature ID is resolved here (after senderEmail is finalised) so DefaultReplyID
|
||||
// matches the correct usage. The actual image download in resolveSignature is deferred
|
||||
// to after applyTemplate so the final plainText value (which a template can override
|
||||
// via IsPlainTextMode) is used for the downloadImages decision.
|
||||
signatureID := runtime.Str("signature-id")
|
||||
noSignature := runtime.Bool("no-signature")
|
||||
if noSignature {
|
||||
signatureID = ""
|
||||
} else if signatureID == "" {
|
||||
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, true /*isReply*/)
|
||||
}
|
||||
|
||||
replyTo := orig.replyTo
|
||||
if replyTo == "" {
|
||||
replyTo = orig.headFrom
|
||||
@@ -208,6 +216,14 @@ var MailReply = common.Shortcut{
|
||||
"bccs_count": countAddresses(bccFlag),
|
||||
})
|
||||
}
|
||||
// Resolve signature after template processing so plainText reflects any IsPlainTextMode
|
||||
// override from the template. This avoids downloading HTML signature images when the
|
||||
// template forces plain-text mode, which could cause CDN 403/5xx or timeout errors.
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
|
||||
runtime.Str("signature-id") != "", !plainText)
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
// --subject (explicit override) takes precedence over auto-generated.
|
||||
subjectOverride := strings.TrimSpace(runtime.Str("subject"))
|
||||
|
||||
@@ -311,7 +327,7 @@ var MailReply = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
composedTextBody = bodyStr + quoted
|
||||
composedTextBody = injectPlainTextSignature(bodyStr, sigResult) + quoted
|
||||
bld = bld.TextBody([]byte(composedTextBody))
|
||||
}
|
||||
// Embed template SMALL non-inline attachments regardless of body mode.
|
||||
|
||||
@@ -43,6 +43,7 @@ var MailReplyAll = common.Shortcut{
|
||||
{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
|
||||
signatureFlag,
|
||||
noSignatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
showLintDetailsFlag},
|
||||
@@ -94,7 +95,7 @@ var MailReplyAll = common.Shortcut{
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventFlags(runtime); err != nil {
|
||||
@@ -131,12 +132,7 @@ var MailReplyAll = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
signatureID := runtime.Str("signature-id")
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to fetch original message")
|
||||
@@ -158,6 +154,18 @@ var MailReplyAll = common.Shortcut{
|
||||
senderEmail = orig.headTo
|
||||
}
|
||||
|
||||
// Signature ID is resolved here (after senderEmail is finalised) so DefaultReplyID
|
||||
// matches the correct usage. The actual image download in resolveSignature is deferred
|
||||
// to after applyTemplate so the final plainText value (which a template can override
|
||||
// via IsPlainTextMode) is used for the downloadImages decision.
|
||||
signatureID := runtime.Str("signature-id")
|
||||
noSignature := runtime.Bool("no-signature")
|
||||
if noSignature {
|
||||
signatureID = ""
|
||||
} else if signatureID == "" {
|
||||
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, true /*isReply*/)
|
||||
}
|
||||
|
||||
var removeList []string
|
||||
for _, r := range strings.Split(removeFlag, ",") {
|
||||
if s := strings.TrimSpace(r); s != "" {
|
||||
@@ -218,6 +226,14 @@ var MailReplyAll = common.Shortcut{
|
||||
"bccs_count": countAddresses(bccFlag),
|
||||
})
|
||||
}
|
||||
// Resolve signature after template processing so plainText reflects any IsPlainTextMode
|
||||
// override from the template. This avoids downloading HTML signature images when the
|
||||
// template forces plain-text mode, which could cause CDN 403/5xx or timeout errors.
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
|
||||
runtime.Str("signature-id") != "", !plainText)
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
subjectOverride := strings.TrimSpace(runtime.Str("subject"))
|
||||
|
||||
if err := validateRecipientCount(toList, ccList, bccFlag); err != nil {
|
||||
@@ -316,7 +332,7 @@ var MailReplyAll = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
composedTextBody = bodyStr + quoted
|
||||
composedTextBody = injectPlainTextSignature(bodyStr, sigResult) + quoted
|
||||
bld = bld.TextBody([]byte(composedTextBody))
|
||||
}
|
||||
// Embed template SMALL non-inline attachments regardless of body mode.
|
||||
|
||||
@@ -40,6 +40,7 @@ var MailSend = common.Shortcut{
|
||||
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
|
||||
signatureFlag,
|
||||
noSignatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
showLintDetailsFlag},
|
||||
@@ -98,7 +99,7 @@ var MailSend = common.Shortcut{
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve the body content first (reading --body-file if set) so
|
||||
@@ -145,6 +146,14 @@ var MailSend = common.Shortcut{
|
||||
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
|
||||
// Auto-resolve default signature when neither --no-signature nor --signature-id is set.
|
||||
noSignature := runtime.Bool("no-signature")
|
||||
if noSignature {
|
||||
signatureID = ""
|
||||
} else if signatureID == "" {
|
||||
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, false)
|
||||
}
|
||||
|
||||
// --template-id merge: fetch template and apply it to compose state.
|
||||
var templateLargeAttachmentIDs []string
|
||||
var templateInlineAttachments []templateInlineRef
|
||||
@@ -195,7 +204,8 @@ var MailSend = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
|
||||
runtime.Str("signature-id") != "", !plainText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -230,7 +240,7 @@ var MailSend = common.Shortcut{
|
||||
// `lint_applied[]` / `original_blocked[]` even on the plain-text path.
|
||||
lintApplied, lintBlocked := emptyLintEnvelopeFields()
|
||||
if plainText {
|
||||
composedTextBody = body
|
||||
composedTextBody = injectPlainTextSignature(body, sigResult)
|
||||
bld = bld.TextBody([]byte(composedTextBody))
|
||||
} else if bodyIsHTML(body) || sigResult != nil {
|
||||
// If signature is requested on plain-text body, auto-upgrade to HTML.
|
||||
|
||||
@@ -6,6 +6,7 @@ package signature
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -55,6 +56,33 @@ func List(runtime *common.RuntimeContext, mailboxID string) ([]Signature, error)
|
||||
return resp.Signatures, nil
|
||||
}
|
||||
|
||||
// DefaultSendID returns the default send-mail signature ID for the given
|
||||
// sender email address. Returns "" if no default is configured.
|
||||
// "0" and empty string are treated as "no default" (API convention).
|
||||
func DefaultSendID(usages []SignatureUsage, emailAddr string) string {
|
||||
for _, u := range usages {
|
||||
if strings.EqualFold(u.EmailAddress, emailAddr) {
|
||||
if u.SendMailSignatureID != "" && u.SendMailSignatureID != "0" {
|
||||
return u.SendMailSignatureID
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DefaultReplyID returns the default reply/forward signature ID for the given
|
||||
// sender email address. Returns "" if no default is configured.
|
||||
func DefaultReplyID(usages []SignatureUsage, emailAddr string) string {
|
||||
for _, u := range usages {
|
||||
if strings.EqualFold(u.EmailAddress, emailAddr) {
|
||||
if u.ReplySignatureID != "" && u.ReplySignatureID != "0" {
|
||||
return u.ReplySignatureID
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Get returns a single signature by ID. Returns an error if not found.
|
||||
func Get(runtime *common.RuntimeContext, mailboxID, signatureID string) (*Signature, error) {
|
||||
resp, err := ListAll(runtime, mailboxID)
|
||||
|
||||
92
shortcuts/mail/signature/provider_test.go
Normal file
92
shortcuts/mail/signature/provider_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package signature
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultSendID_Match(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "user@example.com", SendMailSignatureID: "sig-send-1", ReplySignatureID: "sig-reply-1"},
|
||||
}
|
||||
if got := DefaultSendID(usages, "user@example.com"); got != "sig-send-1" {
|
||||
t.Fatalf("expected sig-send-1, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSendID_CaseInsensitive(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "User@Example.COM", SendMailSignatureID: "sig-send-x"},
|
||||
}
|
||||
if got := DefaultSendID(usages, "user@example.com"); got != "sig-send-x" {
|
||||
t.Fatalf("expected case-insensitive match, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSendID_NoMatch(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "other@example.com", SendMailSignatureID: "sig-other"},
|
||||
}
|
||||
if got := DefaultSendID(usages, "user@example.com"); got != "" {
|
||||
t.Fatalf("expected empty string for no match, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSendID_ZeroIDTreatedAsNone(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "user@example.com", SendMailSignatureID: "0"},
|
||||
}
|
||||
if got := DefaultSendID(usages, "user@example.com"); got != "" {
|
||||
t.Fatalf("expected empty string for ID=0, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSendID_NilUsages(t *testing.T) {
|
||||
if got := DefaultSendID(nil, "user@example.com"); got != "" {
|
||||
t.Fatalf("expected empty string for nil usages, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultReplyID_Match(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "user@example.com", SendMailSignatureID: "sig-send-1", ReplySignatureID: "sig-reply-2"},
|
||||
}
|
||||
if got := DefaultReplyID(usages, "user@example.com"); got != "sig-reply-2" {
|
||||
t.Fatalf("expected sig-reply-2, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultReplyID_CaseInsensitive(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "User@Example.COM", ReplySignatureID: "sig-reply-x"},
|
||||
}
|
||||
if got := DefaultReplyID(usages, "user@example.com"); got != "sig-reply-x" {
|
||||
t.Fatalf("expected case-insensitive match, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultReplyID_NoMatch(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "other@example.com", ReplySignatureID: "sig-reply-other"},
|
||||
}
|
||||
if got := DefaultReplyID(usages, "user@example.com"); got != "" {
|
||||
t.Fatalf("expected empty string for no match, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultReplyID_ZeroIDTreatedAsNone(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "user@example.com", ReplySignatureID: "0"},
|
||||
}
|
||||
if got := DefaultReplyID(usages, "user@example.com"); got != "" {
|
||||
t.Fatalf("expected empty string for ID=0, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultReplyID_NilUsages(t *testing.T) {
|
||||
if got := DefaultReplyID(nil, "user@example.com"); got != "" {
|
||||
t.Fatalf("expected empty string for nil usages, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -25,6 +26,54 @@ var signatureFlag = common.Flag{
|
||||
Desc: "Optional. Signature ID to append after body content. Run `mail +signature` to list available signatures.",
|
||||
}
|
||||
|
||||
// noSignatureFlag is shared by all 5 compose shortcuts.
|
||||
var noSignatureFlag = common.Flag{
|
||||
Name: "no-signature",
|
||||
Type: "bool",
|
||||
Desc: "Skip automatic default signature insertion. Mutually exclusive with --signature-id.",
|
||||
}
|
||||
|
||||
// validateNoSignatureConflict returns a structured validation error when
|
||||
// --no-signature and --signature-id are both set; they are mutually exclusive.
|
||||
func validateNoSignatureConflict(noSignature bool, signatureID string) error {
|
||||
if noSignature && signatureID != "" {
|
||||
return mailValidationParamError("--no-signature", "--no-signature and --signature-id are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// autoResolveSignatureID resolves the default signature ID for the given mailbox/sender.
|
||||
// isReply=true uses DefaultReplyID (+reply/+reply-all/+forward);
|
||||
// isReply=false uses DefaultSendID (+send/+draft-create).
|
||||
// Returns "" on API failure (writes stderr warning) or when no default is configured.
|
||||
func autoResolveSignatureID(runtime *common.RuntimeContext, mailboxID, senderEmail string, isReply bool) string {
|
||||
resp, err := signature.ListAll(runtime, mailboxID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"warning: failed to fetch default signature: %v; sending without signature\n", err)
|
||||
return ""
|
||||
}
|
||||
if isReply {
|
||||
return signature.DefaultReplyID(resp.Usages, senderEmail)
|
||||
}
|
||||
return signature.DefaultSendID(resp.Usages, senderEmail)
|
||||
}
|
||||
|
||||
// injectPlainTextSignature appends a plain-text rendering of the signature to a
|
||||
// plain-text body. The HTML signature (sig.RenderedContent) is converted via
|
||||
// draftpkg.PlainTextFromHTML; inline images are dropped (plain text has none).
|
||||
// Returns textBody unchanged when sig is nil.
|
||||
func injectPlainTextSignature(textBody string, sig *signatureResult) string {
|
||||
if sig == nil {
|
||||
return textBody
|
||||
}
|
||||
sigText := strings.TrimRight(draftpkg.PlainTextFromHTML(sig.RenderedContent), "\n")
|
||||
if sigText == "" {
|
||||
return textBody
|
||||
}
|
||||
return textBody + "\n\n" + sigText
|
||||
}
|
||||
|
||||
// signatureResult holds the pre-processed signature data ready for HTML injection.
|
||||
type signatureResult struct {
|
||||
ID string
|
||||
@@ -32,16 +81,32 @@ type signatureResult struct {
|
||||
Images []draftpkg.SignatureImage
|
||||
}
|
||||
|
||||
// resolveSignature fetches, interpolates, and downloads images for a signature.
|
||||
// resolveSignature fetches, interpolates, and optionally 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.
|
||||
func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string) (*signatureResult, error) {
|
||||
//
|
||||
// userExplicit must be true when the caller obtained signatureID from a user-supplied flag
|
||||
// (--signature-id); false when the ID was auto-resolved from default usages. When false,
|
||||
// a "not found" error from the signatures API is treated as graceful degradation (no
|
||||
// signature) rather than a hard failure — this protects against stale default IDs.
|
||||
//
|
||||
// includeImages controls whether inline image attachments are downloaded. Pass false for
|
||||
// plain-text compose paths to avoid unnecessary network I/O (images are discarded in
|
||||
// plain-text mode anyway).
|
||||
func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string, userExplicit, includeImages bool) (*signatureResult, error) {
|
||||
if signatureID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sig, err := signature.Get(runtime, mailboxID, signatureID)
|
||||
if err != nil {
|
||||
if !userExplicit && errs.IsValidation(err) {
|
||||
// Stale auto-resolved default signature ID — degrade gracefully instead of
|
||||
// blocking the entire send/reply/forward operation.
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"warning: default signature %q not found in current list; sending without signature\n", signatureID)
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -50,23 +115,26 @@ func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailb
|
||||
senderName, senderEmail := resolveSenderInfo(runtime, mailboxID, fromEmail)
|
||||
rendered := signature.InterpolateTemplate(sig, lang, senderName, senderEmail)
|
||||
|
||||
// Download signature inline images. The file_key field contains a
|
||||
// direct download URL provided by the mail backend.
|
||||
// Download signature inline images only when the compose path needs them.
|
||||
// Plain-text paths discard images, so skip the download to avoid unnecessary
|
||||
// network I/O (and potential failures from expired pre-signed URLs).
|
||||
var images []draftpkg.SignatureImage
|
||||
for _, img := range sig.Images {
|
||||
if img.DownloadURL == "" || img.CID == "" {
|
||||
continue
|
||||
if includeImages {
|
||||
for _, img := range sig.Images {
|
||||
if img.DownloadURL == "" || img.CID == "" {
|
||||
continue
|
||||
}
|
||||
data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
|
||||
if err != nil {
|
||||
return nil, mailDecorateProblemMessage(err, "failed to download signature image %s", img.ImageName)
|
||||
}
|
||||
images = append(images, draftpkg.SignatureImage{
|
||||
CID: img.CID,
|
||||
ContentType: ct,
|
||||
FileName: img.ImageName,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
|
||||
if err != nil {
|
||||
return nil, mailDecorateProblemMessage(err, "failed to download signature image %s", img.ImageName)
|
||||
}
|
||||
images = append(images, draftpkg.SignatureImage{
|
||||
CID: img.CID,
|
||||
ContentType: ct,
|
||||
FileName: img.ImageName,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
return &signatureResult{
|
||||
@@ -243,15 +311,3 @@ func signatureCIDs(sig *signatureResult) []string {
|
||||
}
|
||||
return cids
|
||||
}
|
||||
|
||||
// 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 nil
|
||||
}
|
||||
|
||||
@@ -4,204 +4,287 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
)
|
||||
|
||||
func TestDownloadSignatureImageRejectsInvalidURLs(t *testing.T) {
|
||||
rt := newDownloadRuntime(t, &http.Client{})
|
||||
func TestValidateNoSignatureConflictTypedError(t *testing.T) {
|
||||
err := validateNoSignatureConflict(true, "sig_123")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
// mailValidationParamError returns *errs.ValidationError.
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if valErr.Param != "--no-signature" {
|
||||
t.Errorf("expected Param \"--no-signature\", got %q", valErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("error message = %q, want it to contain \"mutually exclusive\"", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNoSignatureConflictNoError(t *testing.T) {
|
||||
if err := validateNoSignatureConflict(false, "sig_123"); err != nil {
|
||||
t.Fatalf("expected no error when noSignature=false, got %v", err)
|
||||
}
|
||||
if err := validateNoSignatureConflict(true, ""); err != nil {
|
||||
t.Fatalf("expected no error when signatureID empty, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPlainTextSignatureNilSig(t *testing.T) {
|
||||
body := "Hello world"
|
||||
got := injectPlainTextSignature(body, nil)
|
||||
if got != body {
|
||||
t.Fatalf("expected unchanged body %q, got %q", body, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPlainTextSignatureEmptyHTML(t *testing.T) {
|
||||
sig := &signatureResult{RenderedContent: " <br> "}
|
||||
body := "Hello world"
|
||||
got := injectPlainTextSignature(body, sig)
|
||||
// PlainTextFromHTML on whitespace-only HTML collapses to empty — body unchanged.
|
||||
if got != body {
|
||||
t.Fatalf("expected unchanged body for empty HTML sig, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPlainTextSignatureAppendsWithBlankLine(t *testing.T) {
|
||||
sig := &signatureResult{RenderedContent: "<div>Best,<br>Bob</div>"}
|
||||
body := "Hello world"
|
||||
got := injectPlainTextSignature(body, sig)
|
||||
if !strings.HasPrefix(got, body+"\n\n") {
|
||||
t.Fatalf("expected body followed by two newlines, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Best,") || !strings.Contains(got, "Bob") {
|
||||
t.Fatalf("expected sig text in result, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPlainTextSignatureTrimsTrailingNewlines(t *testing.T) {
|
||||
// RenderedContent whose plain-text rendering ends in newlines must be trimmed.
|
||||
sig := &signatureResult{RenderedContent: "<p>Alice</p>"}
|
||||
body := "My message"
|
||||
got := injectPlainTextSignature(body, sig)
|
||||
// Result must not end with a bare newline after the signature text.
|
||||
if strings.HasSuffix(got, "\n") {
|
||||
t.Fatalf("result should not end with newline, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Alice") {
|
||||
t.Fatalf("expected sig text in result, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentTypeFromFilename(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{name: "invalid", url: "https://[::1"},
|
||||
{name: "http", url: "http://example.com/sig.png"},
|
||||
{name: "no host", url: "https:///sig.png"},
|
||||
{"logo.png", "image/png"},
|
||||
{"photo.jpg", "image/jpeg"},
|
||||
{"photo.jpeg", "image/jpeg"},
|
||||
{"anim.gif", "image/gif"},
|
||||
{"icon.webp", "image/webp"},
|
||||
{"draw.svg", "image/svg+xml"},
|
||||
{"bitmap.bmp", "image/bmp"},
|
||||
{"data.bin", "application/octet-stream"},
|
||||
{"noext", "application/octet-stream"},
|
||||
{"UPPER.PNG", "image/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)
|
||||
}
|
||||
})
|
||||
got := contentTypeFromFilename(tc.name)
|
||||
if got != tc.want {
|
||||
t.Errorf("contentTypeFromFilename(%q) = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
func TestSignatureCIDsNilSig(t *testing.T) {
|
||||
if cids := signatureCIDs(nil); cids != nil {
|
||||
t.Fatalf("expected nil slice for nil sig, got %v", cids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureCIDsFiltersEmpty(t *testing.T) {
|
||||
sig := &signatureResult{
|
||||
Images: []draftpkg.SignatureImage{
|
||||
{CID: "abc123"},
|
||||
{CID: ""},
|
||||
{CID: "<def456>"},
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
statusCode: http.StatusNotFound,
|
||||
wantType: (*errs.APIError)(nil),
|
||||
wantSub: errs.SubtypeNotFound,
|
||||
}
|
||||
cids := signatureCIDs(sig)
|
||||
// normalizeInlineCID strips angle brackets; empty CID is filtered out.
|
||||
if len(cids) != 2 {
|
||||
t.Fatalf("expected 2 CIDs, got %d: %v", len(cids), cids)
|
||||
}
|
||||
for _, c := range cids {
|
||||
if c == "" {
|
||||
t.Errorf("CID must not be empty string; got %v", cids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSignatureIntoBodyNilSig(t *testing.T) {
|
||||
html := "<div>body</div>"
|
||||
got := injectSignatureIntoBody(html, nil)
|
||||
if got != html {
|
||||
t.Fatalf("expected unchanged body for nil sig, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSignatureIntoBodyInjectsSig(t *testing.T) {
|
||||
html := "<div>Hello</div>"
|
||||
sig := &signatureResult{
|
||||
ID: "sig1",
|
||||
RenderedContent: "<div>-- Alice</div>",
|
||||
}
|
||||
got := injectSignatureIntoBody(html, sig)
|
||||
if !strings.Contains(got, "sig1") && !strings.Contains(got, "Alice") {
|
||||
t.Fatalf("expected signature content in result, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddSignatureImagesToBuilderNilSig(t *testing.T) {
|
||||
bld := emlbuilder.New()
|
||||
got := addSignatureImagesToBuilder(bld, nil)
|
||||
// nil sig must return the builder unchanged (no panic, no nil return).
|
||||
_ = got
|
||||
}
|
||||
|
||||
func TestAddSignatureImagesToBuilderWithImages(t *testing.T) {
|
||||
bld := emlbuilder.New()
|
||||
sig := &signatureResult{
|
||||
Images: []draftpkg.SignatureImage{
|
||||
{CID: "img1", ContentType: "image/png", FileName: "logo.png", Data: []byte("fake")},
|
||||
{CID: "", ContentType: "image/jpeg", FileName: "skip.jpg", Data: []byte("fake")}, // empty CID skipped
|
||||
},
|
||||
} {
|
||||
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())
|
||||
}
|
||||
// Should not panic; empty CID entry is silently skipped.
|
||||
got := addSignatureImagesToBuilder(bld, sig)
|
||||
_ = got
|
||||
}
|
||||
|
||||
_, _, 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)
|
||||
}
|
||||
})
|
||||
// newSigTestRuntime creates a RuntimeContext backed by an httpmock.Registry for
|
||||
// tests that exercise signature API code paths (autoResolveSignatureID, resolveSignature).
|
||||
func newSigTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_sigtest"}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+test"}, cfg, f, core.AsUser)
|
||||
return rt, reg
|
||||
}
|
||||
|
||||
// stubSigListResponse registers a signatures list stub for the given mailboxID.
|
||||
func stubSigListResponse(reg *httpmock.Registry, mailboxID string, sigs []map[string]interface{}, usages []map[string]interface{}) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/mail/v1/user_mailboxes/" + mailboxID + "/settings/signatures",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"signatures": sigs,
|
||||
"usages": usages,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestAutoResolveSignatureID_APIFailureReturnsEmpty(t *testing.T) {
|
||||
rt, reg := newSigTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/mail/v1/user_mailboxes/mbx-api-fail/settings/signatures",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{"code": 500, "msg": "internal server error"},
|
||||
})
|
||||
got := autoResolveSignatureID(rt, "mbx-api-fail", "user@example.com", false)
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty string on API failure, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}),
|
||||
func TestAutoResolveSignatureID_NoDefaultConfigured(t *testing.T) {
|
||||
rt, reg := newSigTestRuntime(t)
|
||||
stubSigListResponse(reg, "mbx-no-default", nil, []map[string]interface{}{
|
||||
{"email_address": "other@example.com", "send_mail_signature_id": "sig-other"},
|
||||
})
|
||||
|
||||
_, _, 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)
|
||||
got := autoResolveSignatureID(rt, "mbx-no-default", "user@example.com", false)
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty string when no default configured for sender, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}),
|
||||
func TestAutoResolveSignatureID_ReturnsSendID(t *testing.T) {
|
||||
rt, reg := newSigTestRuntime(t)
|
||||
stubSigListResponse(reg, "mbx-send-id", nil, []map[string]interface{}{
|
||||
{"email_address": "user@example.com", "send_mail_signature_id": "sig-send-42", "reply_signature_id": "sig-reply-42"},
|
||||
})
|
||||
got := autoResolveSignatureID(rt, "mbx-send-id", "user@example.com", false)
|
||||
if got != "sig-send-42" {
|
||||
t.Fatalf("expected send default sig ID %q, got %q", "sig-send-42", got)
|
||||
}
|
||||
}
|
||||
|
||||
data, contentType, err := downloadSignatureImage(rt, "https://example.com/sig.gif", "sig.gif")
|
||||
func TestAutoResolveSignatureID_ReturnsReplyID(t *testing.T) {
|
||||
rt, reg := newSigTestRuntime(t)
|
||||
stubSigListResponse(reg, "mbx-reply-id", nil, []map[string]interface{}{
|
||||
{"email_address": "user@example.com", "send_mail_signature_id": "sig-send-42", "reply_signature_id": "sig-reply-42"},
|
||||
})
|
||||
got := autoResolveSignatureID(rt, "mbx-reply-id", "user@example.com", true)
|
||||
if got != "sig-reply-42" {
|
||||
t.Fatalf("expected reply default sig ID %q, got %q", "sig-reply-42", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSignature_EmptyIDReturnsNil(t *testing.T) {
|
||||
rt, _ := newSigTestRuntime(t)
|
||||
result, err := resolveSignature(context.Background(), rt, "mbx-empty", "", "user@example.com", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadSignatureImage failed: %v", err)
|
||||
t.Fatalf("unexpected error for empty signatureID: %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)
|
||||
if result != nil {
|
||||
t.Fatalf("expected nil result for empty signatureID, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
func TestResolveSignature_StaleIDAutoDegradesGracefully(t *testing.T) {
|
||||
rt, reg := newSigTestRuntime(t)
|
||||
// API returns an empty list — stale ID not found → ValidationError in Get.
|
||||
stubSigListResponse(reg, "mbx-stale-auto", nil, nil)
|
||||
result, err := resolveSignature(context.Background(), rt, "mbx-stale-auto", "sig-stale", "user@example.com", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("expected graceful degradation (nil error), got: %v", 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)
|
||||
if result != nil {
|
||||
t.Fatalf("expected nil result for stale auto-resolved ID, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
func TestResolveSignature_StaleIDUserExplicitFails(t *testing.T) {
|
||||
rt, reg := newSigTestRuntime(t)
|
||||
stubSigListResponse(reg, "mbx-stale-explicit", nil, nil)
|
||||
_, err := resolveSignature(context.Background(), rt, "mbx-stale-explicit", "sig-stale", "user@example.com", true, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for stale ID with userExplicit=true, got nil")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,18 +177,6 @@ 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,
|
||||
|
||||
@@ -150,12 +150,6 @@ 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)},
|
||||
|
||||
@@ -25,119 +25,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-list": {
|
||||
"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": "cursor",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Pagination cursor; pass the previous page's next_cursor, omit for first page"
|
||||
},
|
||||
{
|
||||
"name": "count",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "History versions per page, default 20"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-revert": {
|
||||
"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": "revision-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Restore the whole spreadsheet to this version: a revision_id (minor id) from +history-list"
|
||||
},
|
||||
{
|
||||
"name": "edit-time",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "The matching edit_time from the same +history-list entry; pass it to locate the version faster"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-revert-status": {
|
||||
"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": "task-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Revert task id returned by +history-revert"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-create": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
@@ -167,7 +54,7 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Insert position (0-based); appended to the end when omitted",
|
||||
"desc": "Insert position; appended to the end when omitted",
|
||||
"default": "-1"
|
||||
},
|
||||
{
|
||||
@@ -526,86 +413,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+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": [
|
||||
@@ -623,46 +430,28 @@
|
||||
"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": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "values",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
||||
"desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`",
|
||||
"input": [
|
||||
"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:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "styles",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dataframe",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
@@ -724,32 +513,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+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": [
|
||||
@@ -1449,72 +1212,19 @@
|
||||
"desc": "Skip hidden rows and columns; default `false`"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"name": "rows-json",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"
|
||||
},
|
||||
{
|
||||
"name": "dataframe-out",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."
|
||||
"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": ""
|
||||
"desc": "Print the request path and parameters without executing"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2139,7 +1849,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).",
|
||||
"desc": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -2170,61 +1880,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+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": "xor",
|
||||
"desc": "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dataframe",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."
|
||||
},
|
||||
{
|
||||
"name": "styles",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`.",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+cells-clear": {
|
||||
"risk": "high-risk-write",
|
||||
"flags": [
|
||||
|
||||
@@ -462,21 +462,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"mention_type": {
|
||||
"description": "@提及类型编号(仅 type='mention' 时可选)。0 或不填=@用户;@文件时按类型取:1=文档 3=电子表格 8=多维表格 11=思维笔记 12=文件 15=旧版幻灯片 16=知识库 22=新版文档 30=幻灯片 38=画板",
|
||||
"type": "number",
|
||||
"enum": [
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
8,
|
||||
11,
|
||||
12,
|
||||
15,
|
||||
16,
|
||||
22,
|
||||
30,
|
||||
38
|
||||
]
|
||||
"description": "@提及类型编号(仅 type='mention' 时可选)",
|
||||
"type": "number"
|
||||
},
|
||||
"notify": {
|
||||
"description": "是否发送通知(仅 type='mention' 时可选,默认 true)",
|
||||
@@ -1743,12 +1730,11 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -1801,7 +1787,11 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
},
|
||||
"+chart-update": {
|
||||
@@ -2779,12 +2769,11 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -2837,7 +2826,11 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
},
|
||||
"+cond-format-create": {
|
||||
@@ -6256,744 +6249,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"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": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"+workbook-create": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"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": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,17 +365,14 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
// The write reads the workbook structure to resolve the default sheet's id
|
||||
// (the create response doesn't echo it). lookupFirstSheetID and
|
||||
// writeTypedSheets' listSheetIDsByName both read it — one reusable stub serves
|
||||
// both. The synthesized sheet is named "Sheet1", matching the default sheet,
|
||||
// so it's adopted in place (no rename).
|
||||
// Initial fill first reads the workbook structure to resolve the default
|
||||
// sheet's id (the create response doesn't echo it), then writes.
|
||||
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
|
||||
structure.Reusable = true
|
||||
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95]]`,
|
||||
}, create, structure, fill)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
@@ -385,8 +382,8 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
if ss["spreadsheet_token"] != "shtcnBRAND" {
|
||||
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
|
||||
}
|
||||
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
|
||||
t.Errorf("sheets summary missing in envelope; got %#v", data["sheets"])
|
||||
if data["initial_fill"] == nil {
|
||||
t.Errorf("initial_fill missing in envelope")
|
||||
}
|
||||
// The fill must target the resolved first sheet, not an empty selector.
|
||||
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
|
||||
@@ -396,13 +393,14 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
|
||||
// panic / illegal-range bug: --values '[]' must short-circuit the initial fill
|
||||
// (no structure/fill calls fire) and finish with the spreadsheet created but no
|
||||
// sheets summary — never panic on a nil payload.
|
||||
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
|
||||
// the initial fill (no structure/fill calls fire) and finish with the
|
||||
// spreadsheet created but no initial_fill — never panic on a nil fill map.
|
||||
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct{ name, flag, val string }{
|
||||
{"empty values", "--values", "[]"},
|
||||
{"empty headers", "--headers", "[]"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -423,8 +421,8 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["sheets"] != nil {
|
||||
t.Errorf("sheets should be absent for %s %s; got %#v", tc.flag, tc.val, data["sheets"])
|
||||
if data["initial_fill"] != nil {
|
||||
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
|
||||
}
|
||||
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
|
||||
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])
|
||||
|
||||
@@ -308,6 +308,7 @@ 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"},
|
||||
},
|
||||
},
|
||||
@@ -319,7 +320,7 @@ var flagDefs = map[string]commandDef{
|
||||
{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: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).", Input: []string{"file", "stdin"}},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -632,35 +633,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+history-list": {
|
||||
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: "cursor", Kind: "own", Type: "string", Required: "optional", Desc: "Pagination cursor; pass the previous page's next_cursor, omit for first page"},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "optional", Desc: "History versions per page, default 20"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+history-revert": {
|
||||
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: "revision-id", Kind: "own", Type: "string", Required: "required", Desc: "Restore the whole spreadsheet to this version: a revision_id (minor id) from +history-list"},
|
||||
{Name: "edit-time", Kind: "own", Type: "string", Required: "optional", Desc: "The matching edit_time from the same +history-list entry; pass it to locate the version faster"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+history-revert-status": {
|
||||
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: "task-id", Kind: "own", Type: "string", Required: "required", Desc: "Revert task id returned by +history-revert"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -794,7 +766,7 @@ var flagDefs = map[string]commandDef{
|
||||
{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: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; appended to the end when omitted", Default: "-1"},
|
||||
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
|
||||
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -821,16 +793,6 @@ 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{
|
||||
@@ -877,16 +839,6 @@ 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{
|
||||
@@ -943,39 +895,13 @@ 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: "dataframe-out", Kind: "own", Type: "string", Required: "optional", Desc: "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."},
|
||||
{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: "xor", Desc: "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).", Input: []string{"file", "stdin"}},
|
||||
{Name: "dataframe", Kind: "own", Type: "string", Required: "xor", Desc: "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", 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:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
|
||||
{Name: "dataframe", Kind: "own", Type: "string", Required: "optional", Desc: "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."},
|
||||
{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: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -990,14 +916,6 @@ 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{
|
||||
|
||||
@@ -63,7 +63,6 @@ func validateParsedJSONFlag(fv flagView, name string, value interface{}) error {
|
||||
var parseJSONFlagSkip = map[string]struct{}{
|
||||
"properties": {},
|
||||
"operations": {},
|
||||
"styles": {},
|
||||
}
|
||||
|
||||
// validateValueAgainstSchema is the (command, flag) → schema → check
|
||||
|
||||
@@ -32,6 +32,4 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+range-sort": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
"+table-put": {},
|
||||
"+workbook-create": {},
|
||||
}
|
||||
|
||||
@@ -1,553 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/apache/arrow/go/v17/arrow"
|
||||
"github.com/apache/arrow/go/v17/arrow/array"
|
||||
"github.com/apache/arrow/go/v17/arrow/ipc"
|
||||
"github.com/apache/arrow/go/v17/arrow/memory"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── --dataframe (Arrow IPC / Feather v2 binary input) ────────────────
|
||||
//
|
||||
// --dataframe is the binary-typed twin of --sheets. The wire payload is one
|
||||
// Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()`
|
||||
// writes), single schema, optionally multi-batch. Type / format are read off
|
||||
// the Arrow schema (no separate dtypes/formats maps), and per-column number
|
||||
// format can be set via the field's `number_format` metadata key:
|
||||
//
|
||||
// pa.field("price", pa.float64(), metadata={b"number_format": b"$#,##0.00"})
|
||||
//
|
||||
// One DataFrame writes into one sub-sheet at fixed defaults: name `Sheet1`
|
||||
// (adopted in place by +workbook-create; created when absent by +table-put),
|
||||
// overwrite from A1 with header on, allow_overwrite=true. The shortcut
|
||||
// surface is deliberately the one flag — anything that needs a different
|
||||
// sheet name / anchor / mode / multi-sheet falls back to --sheets, whose
|
||||
// JSON payload already carries every knob.
|
||||
//
|
||||
// Binary IO note: --dataframe bypasses the text-oriented Input resolver
|
||||
// (`runtime.Str("dataframe")` carries a *path*, not file contents). Reading
|
||||
// the Arrow bytes through that resolver would TrimSpace the trailing IPC
|
||||
// magic / corrupt non-UTF8 bytes. Path → FileIO.Open → io.ReadAll keeps the
|
||||
// stream byte-exact. "-" reads from stdin directly.
|
||||
|
||||
// dataframeDefaultSheetName is the sub-sheet name --dataframe writes into.
|
||||
// Matches valuesSheetName so +workbook-create adopts the brand-new
|
||||
// workbook's default sheet in place (no stray empty Sheet1 left behind);
|
||||
// +table-put creates Sheet1 if it doesn't already exist.
|
||||
const dataframeDefaultSheetName = valuesSheetName
|
||||
|
||||
// parseDataframePayload reads the --dataframe path (Arrow IPC file) and
|
||||
// composes a single-sheet tablePayload at the fixed default placement.
|
||||
// Network-free: safe from Validate and DryRun. The resulting tableSheetSpec
|
||||
// rides the same buildSheetMatrix / buildTypedCell path as a --sheets entry,
|
||||
// so downstream is unaware of where the rows came from.
|
||||
func parseDataframePayload(rctx *common.RuntimeContext) (*tablePayload, error) {
|
||||
raw := strings.TrimSpace(rctx.Str("dataframe"))
|
||||
if raw == "" {
|
||||
return nil, common.FlagErrorf("--dataframe is required")
|
||||
}
|
||||
data, err := readDataframeBytes(rctx, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec, err := decodeArrowToSheet(data, dataframeDefaultSheetName)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("--dataframe: %v", err)
|
||||
}
|
||||
payload := &tablePayload{Sheets: []tableSheetSpec{spec}}
|
||||
if err := payload.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// dataframeStdinCache holds the bytes read from stdin on the first call so a
|
||||
// later call (Validate → Execute / DryRun) gets the same bytes instead of an
|
||||
// empty stream — stdin is single-shot, but parseDataframePayload runs
|
||||
// multiple times per command invocation. Process-wide is fine: lark-cli is
|
||||
// one-shot (one command per process). Tests reset by setting it back to nil.
|
||||
var dataframeStdinCache []byte
|
||||
|
||||
// readDataframeBytes resolves --dataframe to raw binary. A literal `@` prefix
|
||||
// is tolerated for symmetry with --sheets (`@/tmp/x.arrow` and `/tmp/x.arrow`
|
||||
// both work). `-` reads stdin verbatim — cached on first call so Validate /
|
||||
// Execute / DryRun all see the same bytes. Bytes are returned untouched: no
|
||||
// TrimSpace, no BOM strip — both would corrupt an Arrow IPC stream.
|
||||
func readDataframeBytes(rctx *common.RuntimeContext, raw string) ([]byte, error) {
|
||||
if raw == "-" {
|
||||
if dataframeStdinCache != nil {
|
||||
return dataframeStdinCache, nil
|
||||
}
|
||||
io := rctx.IO()
|
||||
if io == nil || io.In == nil {
|
||||
return nil, common.FlagErrorf("--dataframe: stdin is not available")
|
||||
}
|
||||
data, err := readAllBytes(io.In)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("--dataframe: read stdin: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, common.FlagErrorf("--dataframe: stdin is empty")
|
||||
}
|
||||
dataframeStdinCache = data
|
||||
return data, nil
|
||||
}
|
||||
path := strings.TrimPrefix(raw, "@")
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("--dataframe: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, common.FlagErrorf("--dataframe: file %q is empty", path)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// readAllBytes is a thin wrapper so tests can fake the io.Reader without
|
||||
// importing io. Mirrors io.ReadAll exactly.
|
||||
func readAllBytes(r io.Reader) ([]byte, error) { return io.ReadAll(r) }
|
||||
|
||||
// decodeArrowToSheet reads `data` as an Arrow IPC file (single schema,
|
||||
// possibly multi-batch) and produces a tableSheetSpec with name + columns +
|
||||
// rows filled in. Sheet placement (start_cell / mode / header / overwrite) is
|
||||
// not touched here — parseDataframePayload layers those on from CLI flags.
|
||||
func decodeArrowToSheet(data []byte, sheetName string) (tableSheetSpec, error) {
|
||||
reader, err := ipc.NewFileReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, fmt.Errorf("invalid Arrow IPC file (expected pandas df.to_feather output): %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
schema := reader.Schema()
|
||||
if schema == nil || schema.NumFields() == 0 {
|
||||
return tableSheetSpec{}, fmt.Errorf("Arrow schema has no fields")
|
||||
}
|
||||
|
||||
ncols := schema.NumFields()
|
||||
cols := make([]tableColumnSpec, ncols)
|
||||
seen := make(map[string]bool, ncols)
|
||||
for i := 0; i < ncols; i++ {
|
||||
f := schema.Field(i)
|
||||
name := f.Name
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return tableSheetSpec{}, fmt.Errorf("column %d has empty name", i)
|
||||
}
|
||||
if seen[name] {
|
||||
return tableSheetSpec{}, fmt.Errorf("duplicate column name %q", name)
|
||||
}
|
||||
seen[name] = true
|
||||
typ, format, err := arrowFieldToTypeFormat(f)
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, fmt.Errorf("column %q: %v", name, err)
|
||||
}
|
||||
cols[i] = tableColumnSpec{Name: name, Type: typ, Format: format}
|
||||
}
|
||||
|
||||
var rows [][]interface{}
|
||||
for b := 0; b < reader.NumRecords(); b++ {
|
||||
rec, err := reader.RecordAt(b)
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, fmt.Errorf("read record batch %d: %v", b, err)
|
||||
}
|
||||
batchRows, err := arrowRecordToRows(rec, cols)
|
||||
rec.Release()
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, err
|
||||
}
|
||||
rows = append(rows, batchRows...)
|
||||
}
|
||||
|
||||
return tableSheetSpec{Name: sheetName, Columns: cols, Rows: rows}, nil
|
||||
}
|
||||
|
||||
// arrowFieldToTypeFormat maps an Arrow field to the internal (type, format)
|
||||
// pair. The field's `number_format` metadata key — when present — sets the
|
||||
// Excel number_format string verbatim; otherwise sensible defaults are
|
||||
// applied per type (`@` text for strings, `yyyy-mm-dd` for dates).
|
||||
func arrowFieldToTypeFormat(f arrow.Field) (typ, format string, err error) {
|
||||
if v, ok := f.Metadata.GetValue("number_format"); ok {
|
||||
format = strings.TrimSpace(v)
|
||||
}
|
||||
switch f.Type.(type) {
|
||||
case *arrow.StringType, *arrow.LargeStringType:
|
||||
if format == "" {
|
||||
format = "@"
|
||||
}
|
||||
return "string", format, nil
|
||||
case *arrow.BooleanType:
|
||||
return "bool", format, nil
|
||||
case *arrow.Date32Type, *arrow.Date64Type, *arrow.TimestampType:
|
||||
if format == "" {
|
||||
format = "yyyy-mm-dd"
|
||||
}
|
||||
return "date", format, nil
|
||||
}
|
||||
if isArrowNumericType(f.Type) {
|
||||
return "number", format, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("unsupported Arrow type %s (want string/number/date/bool)", f.Type.Name())
|
||||
}
|
||||
|
||||
func isArrowNumericType(t arrow.DataType) bool {
|
||||
switch t.ID() {
|
||||
case arrow.INT8, arrow.INT16, arrow.INT32, arrow.INT64,
|
||||
arrow.UINT8, arrow.UINT16, arrow.UINT32, arrow.UINT64,
|
||||
arrow.FLOAT16, arrow.FLOAT32, arrow.FLOAT64:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// arrowRecordToRows transposes one column-batch into row-major
|
||||
// [][]interface{} matched to `cols`. Cells are stamped with the same value
|
||||
// shapes buildTypedCell expects from the JSON path: nil for nulls,
|
||||
// json.Number for numerics (precision-preserving), `yyyy-mm-dd` strings for
|
||||
// dates/timestamps, bool for booleans, string for strings.
|
||||
func arrowRecordToRows(rec arrow.Record, cols []tableColumnSpec) ([][]interface{}, error) {
|
||||
if int(rec.NumCols()) != len(cols) {
|
||||
return nil, fmt.Errorf("record has %d cols, schema declared %d", rec.NumCols(), len(cols))
|
||||
}
|
||||
nrows := int(rec.NumRows())
|
||||
rows := make([][]interface{}, nrows)
|
||||
for r := range rows {
|
||||
rows[r] = make([]interface{}, len(cols))
|
||||
}
|
||||
for c := range cols {
|
||||
arr := rec.Column(c)
|
||||
for r := 0; r < nrows; r++ {
|
||||
if arr.IsNull(r) {
|
||||
continue
|
||||
}
|
||||
v, err := arrowCellValue(arr, r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("row %d column %q: %v", r, cols[c].Name, err)
|
||||
}
|
||||
rows[r][c] = v
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func arrowCellValue(arr arrow.Array, i int) (interface{}, error) {
|
||||
switch a := arr.(type) {
|
||||
case *array.String:
|
||||
return a.Value(i), nil
|
||||
case *array.LargeString:
|
||||
return a.Value(i), nil
|
||||
case *array.Boolean:
|
||||
return a.Value(i), nil
|
||||
case *array.Int8:
|
||||
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
|
||||
case *array.Int16:
|
||||
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
|
||||
case *array.Int32:
|
||||
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
|
||||
case *array.Int64:
|
||||
return json.Number(strconv.FormatInt(a.Value(i), 10)), nil
|
||||
case *array.Uint8:
|
||||
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
|
||||
case *array.Uint16:
|
||||
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
|
||||
case *array.Uint32:
|
||||
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
|
||||
case *array.Uint64:
|
||||
return json.Number(strconv.FormatUint(a.Value(i), 10)), nil
|
||||
case *array.Float16:
|
||||
return json.Number(strconv.FormatFloat(float64(a.Value(i).Float32()), 'f', -1, 32)), nil
|
||||
case *array.Float32:
|
||||
return json.Number(strconv.FormatFloat(float64(a.Value(i)), 'f', -1, 32)), nil
|
||||
case *array.Float64:
|
||||
return json.Number(strconv.FormatFloat(a.Value(i), 'f', -1, 64)), nil
|
||||
case *array.Date32:
|
||||
// Date32: days since 1970-01-01 (epoch). Multiply to seconds, format
|
||||
// in UTC so timezone offset can't flip the calendar date.
|
||||
t := time.Unix(int64(a.Value(i))*86400, 0).UTC()
|
||||
return t.Format("2006-01-02"), nil
|
||||
case *array.Date64:
|
||||
t := time.UnixMilli(int64(a.Value(i))).UTC()
|
||||
return t.Format("2006-01-02"), nil
|
||||
case *array.Timestamp:
|
||||
ts := int64(a.Value(i))
|
||||
unit := a.DataType().(*arrow.TimestampType).Unit
|
||||
var t time.Time
|
||||
switch unit {
|
||||
case arrow.Second:
|
||||
t = time.Unix(ts, 0).UTC()
|
||||
case arrow.Millisecond:
|
||||
t = time.UnixMilli(ts).UTC()
|
||||
case arrow.Microsecond:
|
||||
t = time.UnixMicro(ts).UTC()
|
||||
case arrow.Nanosecond:
|
||||
t = time.Unix(0, ts).UTC()
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported timestamp unit %v", unit)
|
||||
}
|
||||
return t.Format("2006-01-02"), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported Arrow array %T", arr)
|
||||
}
|
||||
|
||||
// ─── --dataframe-out (Arrow IPC binary output, mirror of --dataframe) ──
|
||||
//
|
||||
// +table-get's binary read-back: encode one sheet's typed read-back as an
|
||||
// Arrow IPC file (Feather v2), so pandas can `pd.read_feather(path)` /
|
||||
// `pd.read_feather(BytesIO(stdout))` symmetrically with the put side.
|
||||
// Single-sheet only — Arrow IPC carries one schema per file. The JSON path
|
||||
// is unchanged; --dataframe-out swaps the encoder for callers that already
|
||||
// have pandas / pyarrow in their pipeline.
|
||||
|
||||
// encodeSheetMapToArrowIPC turns one readSheetAsSpec output into an Arrow IPC
|
||||
// file blob. Internal column types are recovered from `dtypes` (the wire
|
||||
// proxy for the typed protocol), and per-column `number_format` rides through
|
||||
// as Arrow field metadata so the file feeds straight back into
|
||||
// `+table-put --dataframe`.
|
||||
func encodeSheetMapToArrowIPC(sheet map[string]interface{}) ([]byte, error) {
|
||||
columns, _ := sheet["columns"].([]interface{})
|
||||
if len(columns) == 0 {
|
||||
return nil, fmt.Errorf("sheet has no columns")
|
||||
}
|
||||
dtypes, _ := sheet["dtypes"].(map[string]interface{})
|
||||
formats, _ := sheet["formats"].(map[string]interface{})
|
||||
// `data` arrives as either []interface{} (when the sheet came through a
|
||||
// JSON round-trip / unit-test fixture) or [][]interface{} (the shape
|
||||
// readSheetAsSpec directly emits in production). Accept both — anything
|
||||
// else falls through to a zero-row table.
|
||||
var rawData [][]interface{}
|
||||
switch d := sheet["data"].(type) {
|
||||
case [][]interface{}:
|
||||
rawData = d
|
||||
case []interface{}:
|
||||
rawData = make([][]interface{}, len(d))
|
||||
for i, r := range d {
|
||||
rawData[i], _ = r.([]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
ncols := len(columns)
|
||||
colNames := make([]string, ncols)
|
||||
colTypes := make([]string, ncols)
|
||||
fields := make([]arrow.Field, ncols)
|
||||
for i, c := range columns {
|
||||
name, _ := c.(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("column %d has empty name", i)
|
||||
}
|
||||
colNames[i] = name
|
||||
dt, _ := dtypes[name].(string)
|
||||
colTypes[i] = dtypeToInternalType(dt)
|
||||
var meta arrow.Metadata
|
||||
if formats != nil {
|
||||
if nf, ok := formats[name].(string); ok && strings.TrimSpace(nf) != "" {
|
||||
meta = arrow.NewMetadata([]string{"number_format"}, []string{nf})
|
||||
}
|
||||
}
|
||||
fields[i] = arrow.Field{
|
||||
Name: name,
|
||||
Type: internalTypeToArrowType(colTypes[i]),
|
||||
Nullable: true,
|
||||
Metadata: meta,
|
||||
}
|
||||
}
|
||||
schema := arrow.NewSchema(fields, nil)
|
||||
|
||||
mem := memory.NewGoAllocator()
|
||||
rb := array.NewRecordBuilder(mem, schema)
|
||||
defer rb.Release()
|
||||
for r, row := range rawData {
|
||||
if len(row) != ncols {
|
||||
return nil, fmt.Errorf("row %d has %d cells, want %d", r, len(row), ncols)
|
||||
}
|
||||
for c := 0; c < ncols; c++ {
|
||||
if err := appendArrowCell(rb.Field(c), colTypes[c], row[c]); err != nil {
|
||||
return nil, fmt.Errorf("row %d column %q: %v", r, colNames[c], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
rec := rb.NewRecord()
|
||||
defer rec.Release()
|
||||
|
||||
var buf bytesWriterSeeker
|
||||
w, err := ipc.NewFileWriter(&buf, ipc.WithSchema(schema), ipc.WithAllocator(mem))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ipc.NewFileWriter: %v", err)
|
||||
}
|
||||
if err := w.Write(rec); err != nil {
|
||||
return nil, fmt.Errorf("write record: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close writer: %v", err)
|
||||
}
|
||||
return buf.buf, nil
|
||||
}
|
||||
|
||||
// dtypeToInternalType inverts typeToDtype so the Arrow encoder can pick an
|
||||
// internal column type from the wire-level dtype string. Unknown / object
|
||||
// falls back to string (lossless: every cell is already typed as such).
|
||||
func dtypeToInternalType(dtype string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(dtype)) {
|
||||
case "float64", "float32", "int64", "int32", "int16", "int8",
|
||||
"uint64", "uint32", "uint16", "uint8":
|
||||
return "number"
|
||||
case "bool", "boolean":
|
||||
return "bool"
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(dtype), "datetime") {
|
||||
return "date"
|
||||
}
|
||||
return "string"
|
||||
}
|
||||
|
||||
// internalTypeToArrowType is the put-side dtypeToTypeFormat dual: maps the
|
||||
// internal column type to the Arrow data type the encoder builds a column
|
||||
// with. Numbers go to float64 because +table-get can't tell int from float
|
||||
// from a number_format alone — float64 covers both losslessly for the cell
|
||||
// ranges Lark Sheets accepts.
|
||||
func internalTypeToArrowType(typ string) arrow.DataType {
|
||||
switch typ {
|
||||
case "number":
|
||||
return arrow.PrimitiveTypes.Float64
|
||||
case "date":
|
||||
return arrow.FixedWidthTypes.Date32
|
||||
case "bool":
|
||||
return arrow.FixedWidthTypes.Boolean
|
||||
}
|
||||
return arrow.BinaryTypes.String
|
||||
}
|
||||
|
||||
// appendArrowCell stamps one cell into its column builder. Cell shape matches
|
||||
// what cellToTyped emits on the JSON path: json.Number for numbers, ISO
|
||||
// `yyyy-mm-dd` string for dates, plain string for strings, bool for bools,
|
||||
// nil for empty. Anything off-shape errors so the caller doesn't silently
|
||||
// emit nulls for malformed data.
|
||||
func appendArrowCell(b array.Builder, typ string, v interface{}) error {
|
||||
if v == nil {
|
||||
b.AppendNull()
|
||||
return nil
|
||||
}
|
||||
switch typ {
|
||||
case "string":
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("string expects string value, got %T", v)
|
||||
}
|
||||
b.(*array.StringBuilder).Append(s)
|
||||
case "number":
|
||||
f, err := arrowNumber(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.(*array.Float64Builder).Append(f)
|
||||
case "date":
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("date expects ISO yyyy-mm-dd string, got %T", v)
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return fmt.Errorf("date parse %q: %v", s, err)
|
||||
}
|
||||
b.(*array.Date32Builder).Append(arrow.Date32FromTime(t))
|
||||
case "bool":
|
||||
bb, ok := v.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("bool expects bool, got %T", v)
|
||||
}
|
||||
b.(*array.BooleanBuilder).Append(bb)
|
||||
default:
|
||||
return fmt.Errorf("unsupported internal type %q", typ)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// arrowNumber converts the number cell shape readSheetAsSpec emits
|
||||
// (json.Number) plus the float fallback to float64 for the Arrow builder.
|
||||
func arrowNumber(v interface{}) (float64, error) {
|
||||
switch n := v.(type) {
|
||||
case json.Number:
|
||||
f, err := n.Float64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("number parse %q: %v", n.String(), err)
|
||||
}
|
||||
return f, nil
|
||||
case float64:
|
||||
return n, nil
|
||||
}
|
||||
return 0, fmt.Errorf("number expects numeric value, got %T", v)
|
||||
}
|
||||
|
||||
// bytesWriterSeeker is a 10-line in-memory io.WriteSeeker for
|
||||
// ipc.NewFileWriter, which seeks back to patch a footer offset. Using a
|
||||
// buffer (instead of a temp file or os.Stdout, which isn't seekable) keeps
|
||||
// --dataframe-out's stdout path zero-IO and stays straightforward.
|
||||
type bytesWriterSeeker struct {
|
||||
buf []byte
|
||||
pos int64
|
||||
}
|
||||
|
||||
func (w *bytesWriterSeeker) Write(p []byte) (int, error) {
|
||||
end := w.pos + int64(len(p))
|
||||
if end > int64(len(w.buf)) {
|
||||
w.buf = append(w.buf, make([]byte, end-int64(len(w.buf)))...)
|
||||
}
|
||||
n := copy(w.buf[w.pos:], p)
|
||||
w.pos = end
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (w *bytesWriterSeeker) Seek(offset int64, whence int) (int64, error) {
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
w.pos = offset
|
||||
case io.SeekCurrent:
|
||||
w.pos += offset
|
||||
case io.SeekEnd:
|
||||
w.pos = int64(len(w.buf)) + offset
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown whence %d", whence)
|
||||
}
|
||||
return w.pos, nil
|
||||
}
|
||||
|
||||
// writeDataframeOut dispatches the encoded Arrow bytes to wherever --dataframe-out
|
||||
// points: `-` → process stdout, `@<path>` or plain path → local file. Symmetric
|
||||
// with readDataframeBytes on the input side: same `@` tolerance, same TrimPrefix
|
||||
// semantics, and an absolute path will still get rejected by FileIO's SafePath.
|
||||
func writeDataframeOut(rctx *common.RuntimeContext, raw string, data []byte) error {
|
||||
if raw == "-" {
|
||||
out := rctx.IO()
|
||||
if out == nil || out.Out == nil {
|
||||
return common.FlagErrorf("--dataframe-out: stdout is not available")
|
||||
}
|
||||
if _, err := out.Out.Write(data); err != nil {
|
||||
return fmt.Errorf("--dataframe-out: write stdout: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
path := strings.TrimPrefix(raw, "@")
|
||||
fio := rctx.FileIO()
|
||||
if fio == nil {
|
||||
return common.FlagErrorf("--dataframe-out: file output is not available in this context")
|
||||
}
|
||||
// FileIO.Save validates the path via SafeOutputPath (the same sandbox
|
||||
// readDataframeBytes hits on the input side) and writes atomically, so we
|
||||
// don't need an extra ValidatePath call here.
|
||||
if _, err := fio.Save(path, fileio.SaveOptions{ContentLength: int64(len(data))}, bytes.NewReader(data)); err != nil {
|
||||
return fmt.Errorf("--dataframe-out: write %q: %v", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/arrow/go/v17/arrow"
|
||||
"github.com/apache/arrow/go/v17/arrow/array"
|
||||
"github.com/apache/arrow/go/v17/arrow/ipc"
|
||||
"github.com/apache/arrow/go/v17/arrow/memory"
|
||||
)
|
||||
|
||||
// buildArrowIPC writes one record into a Feather v2 (Arrow IPC file) blob.
|
||||
// Used by the round-trip tests below to stand in for what
|
||||
// `pandas.DataFrame.to_feather(path)` would produce; saves the tests from
|
||||
// depending on a pandas-shaped fixture file.
|
||||
//
|
||||
// ipc.NewFileWriter wants an io.WriteSeeker (it back-patches a footer
|
||||
// offset), so we write to a temp file and read the bytes back — simpler than
|
||||
// re-implementing a seekable in-memory buffer.
|
||||
func buildArrowIPC(t *testing.T, schema *arrow.Schema, build func(b *array.RecordBuilder)) []byte {
|
||||
t.Helper()
|
||||
mem := memory.NewGoAllocator()
|
||||
rb := array.NewRecordBuilder(mem, schema)
|
||||
defer rb.Release()
|
||||
build(rb)
|
||||
rec := rb.NewRecord()
|
||||
defer rec.Release()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "df.arrow")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create temp arrow file: %v", err)
|
||||
}
|
||||
w, err := ipc.NewFileWriter(f, ipc.WithSchema(schema), ipc.WithAllocator(mem))
|
||||
if err != nil {
|
||||
f.Close()
|
||||
t.Fatalf("ipc.NewFileWriter: %v", err)
|
||||
}
|
||||
if err := w.Write(rec); err != nil {
|
||||
t.Fatalf("write record: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close writer: %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatalf("close file: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read temp arrow file: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestDataframe_RoundTripCoreTypes pins down the Arrow-schema → internal
|
||||
// (type, format) mapping and the per-cell value shape that buildTypedCell
|
||||
// expects: number cells are json.Number (precision-preserving), date cells
|
||||
// are `yyyy-mm-dd` strings, bool/string come through verbatim. Numbers, dates,
|
||||
// strings, bools, and nulls all in one record so a future Arrow-Go bump can't
|
||||
// quietly regress any one family.
|
||||
func TestDataframe_RoundTripCoreTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schema := arrow.NewSchema([]arrow.Field{
|
||||
{Name: "name", Type: arrow.BinaryTypes.String},
|
||||
{Name: "qty", Type: arrow.PrimitiveTypes.Int64},
|
||||
{Name: "price", Type: arrow.PrimitiveTypes.Float64, Metadata: arrow.NewMetadata(
|
||||
[]string{"number_format"}, []string{"$#,##0.00"},
|
||||
)},
|
||||
{Name: "active", Type: arrow.FixedWidthTypes.Boolean},
|
||||
{Name: "shipped_on", Type: arrow.FixedWidthTypes.Date32},
|
||||
}, nil)
|
||||
|
||||
jan15 := arrow.Date32FromTime(time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC))
|
||||
feb02 := arrow.Date32FromTime(time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
|
||||
b.Field(0).(*array.StringBuilder).AppendValues([]string{"alice", ""}, []bool{true, false})
|
||||
b.Field(1).(*array.Int64Builder).AppendValues([]int64{42, 0}, []bool{true, false})
|
||||
b.Field(2).(*array.Float64Builder).AppendValues([]float64{19.95, 0}, []bool{true, false})
|
||||
b.Field(3).(*array.BooleanBuilder).AppendValues([]bool{true, false}, []bool{true, true})
|
||||
b.Field(4).(*array.Date32Builder).AppendValues([]arrow.Date32{jan15, feb02}, []bool{true, true})
|
||||
})
|
||||
|
||||
spec, err := decodeArrowToSheet(buf, "S1")
|
||||
if err != nil {
|
||||
t.Fatalf("decodeArrowToSheet: %v", err)
|
||||
}
|
||||
if spec.Name != "S1" {
|
||||
t.Errorf("sheet name = %q, want S1", spec.Name)
|
||||
}
|
||||
if len(spec.Columns) != 5 {
|
||||
t.Fatalf("got %d columns, want 5", len(spec.Columns))
|
||||
}
|
||||
want := []struct{ typ, format string }{
|
||||
{"string", "@"},
|
||||
{"number", ""},
|
||||
{"number", "$#,##0.00"},
|
||||
{"bool", ""},
|
||||
{"date", "yyyy-mm-dd"},
|
||||
}
|
||||
for i, w := range want {
|
||||
if spec.Columns[i].Type != w.typ {
|
||||
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w.typ)
|
||||
}
|
||||
if spec.Columns[i].Format != w.format {
|
||||
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, w.format)
|
||||
}
|
||||
}
|
||||
|
||||
if len(spec.Rows) != 2 {
|
||||
t.Fatalf("got %d rows, want 2", len(spec.Rows))
|
||||
}
|
||||
// Row 0: every field present, types match what buildTypedCell will accept.
|
||||
row0 := spec.Rows[0]
|
||||
if row0[0] != "alice" {
|
||||
t.Errorf("row0[name] = %#v, want \"alice\"", row0[0])
|
||||
}
|
||||
if n, ok := row0[1].(json.Number); !ok || n.String() != "42" {
|
||||
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", row0[1])
|
||||
}
|
||||
if n, ok := row0[2].(json.Number); !ok || n.String() != "19.95" {
|
||||
t.Errorf("row0[price] = %#v, want json.Number(\"19.95\")", row0[2])
|
||||
}
|
||||
if row0[3] != true {
|
||||
t.Errorf("row0[active] = %#v, want true", row0[3])
|
||||
}
|
||||
if row0[4] != "2024-01-15" {
|
||||
t.Errorf("row0[shipped_on] = %#v, want \"2024-01-15\"", row0[4])
|
||||
}
|
||||
|
||||
// Row 1: nulls on name/qty/price (despite the buffer values) must become nil
|
||||
// so buildTypedCell paints an empty cell that still carries number_format.
|
||||
row1 := spec.Rows[1]
|
||||
for _, c := range []int{0, 1, 2} {
|
||||
if row1[c] != nil {
|
||||
t.Errorf("row1[%d] = %#v, want nil (null in arrow)", c, row1[c])
|
||||
}
|
||||
}
|
||||
if row1[3] != false {
|
||||
t.Errorf("row1[active] = %#v, want false", row1[3])
|
||||
}
|
||||
if row1[4] != "2024-02-02" {
|
||||
t.Errorf("row1[shipped_on] = %#v, want \"2024-02-02\"", row1[4])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_Timestamp pins the timestamp → date conversion for the
|
||||
// timestamp[us] case (pandas default for `pd.Timestamp` columns once written
|
||||
// via `to_feather`). Only the calendar date matters for our `yyyy-mm-dd`
|
||||
// landing — guard against TZ drift from the wrong unit pick.
|
||||
func TestDataframe_Timestamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := arrow.NewSchema([]arrow.Field{
|
||||
{Name: "ts", Type: &arrow.TimestampType{Unit: arrow.Microsecond}},
|
||||
}, nil)
|
||||
ts := arrow.Timestamp(time.Date(2024, 6, 12, 14, 30, 0, 0, time.UTC).UnixMicro())
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
|
||||
b.Field(0).(*array.TimestampBuilder).AppendValues([]arrow.Timestamp{ts}, []bool{true})
|
||||
})
|
||||
spec, err := decodeArrowToSheet(buf, "S")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.Columns[0].Type != "date" {
|
||||
t.Errorf("type = %q, want date", spec.Columns[0].Type)
|
||||
}
|
||||
if got := spec.Rows[0][0]; got != "2024-06-12" {
|
||||
t.Errorf("ts = %#v, want \"2024-06-12\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_EmptySchema rejects an Arrow file whose schema has no fields:
|
||||
// a 0-column "DataFrame" would write a header-less, data-less block that
|
||||
// validates as "writer ran successfully" but produces nothing — the test ties
|
||||
// that off as an explicit error rather than letting it slip through.
|
||||
func TestDataframe_EmptySchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := arrow.NewSchema(nil, nil)
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {})
|
||||
_, err := decodeArrowToSheet(buf, "S")
|
||||
if err == nil || !strings.Contains(err.Error(), "no fields") {
|
||||
t.Errorf("err = %v, want 'no fields' error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_DuplicateColumn catches duplicate-name columns at decode
|
||||
// time. Validate already rejects duplicate column names for the JSON path;
|
||||
// the Arrow path mirrors that so the error surfaces with the same shape.
|
||||
func TestDataframe_DuplicateColumn(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := arrow.NewSchema([]arrow.Field{
|
||||
{Name: "x", Type: arrow.BinaryTypes.String},
|
||||
{Name: "x", Type: arrow.PrimitiveTypes.Int64},
|
||||
}, nil)
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
|
||||
b.Field(0).(*array.StringBuilder).Append("")
|
||||
b.Field(1).(*array.Int64Builder).Append(0)
|
||||
})
|
||||
_, err := decodeArrowToSheet(buf, "S")
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Errorf("err = %v, want duplicate-column error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_BadBytes rejects a non-Arrow blob with a hint pointing at
|
||||
// pandas df.to_feather so users see what producer is expected without having
|
||||
// to grep the docs.
|
||||
func TestDataframe_BadBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := decodeArrowToSheet([]byte("not arrow"), "S")
|
||||
if err == nil || !strings.Contains(err.Error(), "Arrow") {
|
||||
t.Errorf("err = %v, want Arrow-decode error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_EncodeRoundTrip checks --dataframe-out's encoder against its
|
||||
// own decoder: build a +table-get-shaped sheet map (the same one
|
||||
// readSheetAsSpec emits), encode to Arrow IPC, decode back via the put-side
|
||||
// decoder, and require the column types / formats / row values to match. If
|
||||
// any encoder choice drifts from what the decoder expects, the round-trip
|
||||
// breaks here long before a real put → get round-trip in production would.
|
||||
func TestDataframe_EncodeRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
sheet := map[string]interface{}{
|
||||
"name": "S1",
|
||||
"columns": []interface{}{"name", "qty", "price", "active", "ts"},
|
||||
"dtypes": map[string]interface{}{
|
||||
"name": "object",
|
||||
"qty": "float64",
|
||||
"price": "float64",
|
||||
"active": "bool",
|
||||
"ts": "datetime64[ns]",
|
||||
},
|
||||
"formats": map[string]interface{}{
|
||||
// `@` is the writer convention for string columns; readSheetAsSpec
|
||||
// strips it via isTextNumberFormat, so an Arrow file built from a
|
||||
// real read won't carry @ either. Keep it absent here to mirror
|
||||
// the production wire shape.
|
||||
"price": "$#,##0.00",
|
||||
},
|
||||
"data": []interface{}{
|
||||
[]interface{}{"alice", json.Number("42"), json.Number("19.95"), true, "2024-01-15"},
|
||||
[]interface{}{"bob", nil, json.Number("8.5"), false, "2024-02-02"},
|
||||
},
|
||||
}
|
||||
blob, err := encodeSheetMapToArrowIPC(sheet)
|
||||
if err != nil {
|
||||
t.Fatalf("encodeSheetMapToArrowIPC: %v", err)
|
||||
}
|
||||
spec, err := decodeArrowToSheet(blob, "S1")
|
||||
if err != nil {
|
||||
t.Fatalf("decodeArrowToSheet: %v", err)
|
||||
}
|
||||
wantTypes := []string{"string", "number", "number", "bool", "date"}
|
||||
wantFormats := []string{"@", "", "$#,##0.00", "", "yyyy-mm-dd"}
|
||||
if len(spec.Columns) != len(wantTypes) {
|
||||
t.Fatalf("got %d columns, want %d", len(spec.Columns), len(wantTypes))
|
||||
}
|
||||
for i, w := range wantTypes {
|
||||
if spec.Columns[i].Type != w {
|
||||
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w)
|
||||
}
|
||||
if spec.Columns[i].Format != wantFormats[i] {
|
||||
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, wantFormats[i])
|
||||
}
|
||||
}
|
||||
if len(spec.Rows) != 2 {
|
||||
t.Fatalf("got %d rows, want 2", len(spec.Rows))
|
||||
}
|
||||
if spec.Rows[0][0] != "alice" {
|
||||
t.Errorf("row0[name] = %#v, want alice", spec.Rows[0][0])
|
||||
}
|
||||
if n, ok := spec.Rows[0][1].(json.Number); !ok || n.String() != "42" {
|
||||
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", spec.Rows[0][1])
|
||||
}
|
||||
if spec.Rows[0][3] != true {
|
||||
t.Errorf("row0[active] = %#v, want true", spec.Rows[0][3])
|
||||
}
|
||||
if spec.Rows[0][4] != "2024-01-15" {
|
||||
t.Errorf("row0[ts] = %#v, want 2024-01-15", spec.Rows[0][4])
|
||||
}
|
||||
// qty is null on row1, must come back as nil (not a zero-valued
|
||||
// json.Number that would later round-trip as 0).
|
||||
if spec.Rows[1][1] != nil {
|
||||
t.Errorf("row1[qty] = %#v, want nil (null arrow cell)", spec.Rows[1][1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_EncodeAcceptsBothRowShapes pins the encoder against the two
|
||||
// shapes `sheet["data"]` actually arrives in: `[][]interface{}` from a live
|
||||
// readSheetAsSpec call (production), and `[]interface{}` from a JSON
|
||||
// unmarshal (round-trip / fixtures). Either must produce non-empty Arrow
|
||||
// output — early on the production shape silently fell through the
|
||||
// `[]interface{}` type assertion and we shipped a 0-row Arrow blob.
|
||||
func TestDataframe_EncodeAcceptsBothRowShapes(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := func(data interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"name": "S",
|
||||
"columns": []interface{}{"city"},
|
||||
"dtypes": map[string]interface{}{"city": "object"},
|
||||
"data": data,
|
||||
}
|
||||
}
|
||||
for label, data := range map[string]interface{}{
|
||||
"production [][]interface{}": [][]interface{}{{"BJ"}, {"SH"}},
|
||||
"unmarshal []interface{}": []interface{}{[]interface{}{"BJ"}, []interface{}{"SH"}},
|
||||
} {
|
||||
blob, err := encodeSheetMapToArrowIPC(base(data))
|
||||
if err != nil {
|
||||
t.Errorf("%s: encode: %v", label, err)
|
||||
continue
|
||||
}
|
||||
spec, err := decodeArrowToSheet(blob, "S")
|
||||
if err != nil {
|
||||
t.Errorf("%s: decode: %v", label, err)
|
||||
continue
|
||||
}
|
||||
if len(spec.Rows) != 2 {
|
||||
t.Errorf("%s: got %d rows, want 2", label, len(spec.Rows))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_DtypeToInternalType pins the inverse of typeToDtype so
|
||||
// readSheetAsSpec's dtype labels recover the right internal type. Covers the
|
||||
// dtype families +table-get emits today plus the safe fallback for unknown
|
||||
// labels (string, lossless).
|
||||
func TestDataframe_DtypeToInternalType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]string{
|
||||
"float64": "number",
|
||||
"int64": "number",
|
||||
"Int64": "number",
|
||||
"bool": "bool",
|
||||
"boolean": "bool",
|
||||
"datetime64[ns]": "date",
|
||||
"datetime64[ms]": "date",
|
||||
"object": "string",
|
||||
"": "string",
|
||||
"weird-new-dtype": "string",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := dtypeToInternalType(in); got != want {
|
||||
t.Errorf("dtypeToInternalType(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_BytesWriterSeeker confirms the in-memory WriteSeeker handles
|
||||
// the Seek-and-overwrite pattern ipc.NewFileWriter uses to patch the footer
|
||||
// offset: write some bytes, seek back to the middle, overwrite, end up with
|
||||
// the buffer reflecting the overwritten bytes (not a tail-extended duplicate).
|
||||
func TestDataframe_BytesWriterSeeker(t *testing.T) {
|
||||
t.Parallel()
|
||||
var w bytesWriterSeeker
|
||||
if _, err := w.Write([]byte("hello world")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Seek(6, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte("WORLD")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(w.buf); got != "hello WORLD" {
|
||||
t.Errorf("buf = %q, want \"hello WORLD\"", got)
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_history ───────────────────────────────────────────────
|
||||
//
|
||||
// Wraps:
|
||||
// - list_history_versions (read) — powers +history-list
|
||||
// - revert_to_revision (write) — powers +history-revert
|
||||
// - get_revert_status (read) — powers +history-revert-status
|
||||
//
|
||||
// The version-history "方案 B": list the spreadsheet's saved revisions, submit
|
||||
// a whole-document revert to one of them, then poll the async revert task for
|
||||
// completion. The facade gateway owns the work; the CLI only forwards the
|
||||
// target revision (and later the task id) and the server does the rest.
|
||||
//
|
||||
// ⚠️ Full-table overwrite: +history-revert rolls the WHOLE spreadsheet back to
|
||||
// the target revision, discarding every change made afterwards — including
|
||||
// other collaborators' (and the web UI's) edits. Use it only on agent scratch
|
||||
// spreadsheets, or when a whole-document rollback is acceptable.
|
||||
|
||||
// HistoryList wraps list_history_versions: page through the spreadsheet's saved
|
||||
// revisions so a later +history-revert can target one by revision_id.
|
||||
var HistoryList = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+history-list",
|
||||
Description: "List the spreadsheet's saved history versions (paginated); use a returned revision_id with +history-revert.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+history-list"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := resolveSpreadsheetToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "list_history_versions", historyListInput(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "list_history_versions", historyListInput(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Omit --cursor for the first page; pass the previous response's next_cursor to fetch the next page.",
|
||||
"Pick a revision_id from a listing entry and pass it (plus that entry's edit_time to --edit-time) to +history-revert to roll the whole spreadsheet back.",
|
||||
},
|
||||
}
|
||||
|
||||
// HistoryRevert wraps revert_to_revision: roll the whole spreadsheet back to a
|
||||
// past revision. Returns an async task id to poll via +history-revert-status.
|
||||
var HistoryRevert = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+history-revert",
|
||||
Description: "Roll the whole spreadsheet back to a past revision (full-document restore; discards all later edits).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+history-revert"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := historyRevertInput(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := historyRevertInput(runtime)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "revert_to_revision", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := historyRevertInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "revert_to_revision", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"+history-revert is a FULL-DOCUMENT rollback — it discards every edit made after the target version, including other collaborators'.",
|
||||
"--revision-id takes a revision_id (minor id) from +history-list; pass the same entry's edit_time to --edit-time to locate the version faster. Poll the returned task id with +history-revert-status.",
|
||||
},
|
||||
}
|
||||
|
||||
// HistoryRevertStatus wraps get_revert_status: poll an async revert task
|
||||
// started by +history-revert for completion.
|
||||
var HistoryRevertStatus = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+history-revert-status",
|
||||
Description: "Poll the status of an async revert task started by +history-revert.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+history-revert-status"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := historyRevertStatusInput(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := historyRevertStatusInput(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_revert_status", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := historyRevertStatusInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_revert_status", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"--task-id is the task id returned by +history-revert; re-run this until the task reports completion.",
|
||||
},
|
||||
}
|
||||
|
||||
// historyListInput builds the list_history_versions tool body. Network-free;
|
||||
// shared by DryRun and Execute. Both flags are optional: cursor is forwarded
|
||||
// only when set, count only when a positive value is given.
|
||||
func historyListInput(runtime flagView) map[string]interface{} {
|
||||
input := map[string]interface{}{}
|
||||
if cursor := strings.TrimSpace(runtime.Str("cursor")); cursor != "" {
|
||||
input["cursor"] = cursor
|
||||
}
|
||||
if count := runtime.Int("count"); count > 0 {
|
||||
input["count"] = count
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// historyRevertInput builds the revert_to_revision tool body. Network-free;
|
||||
// shared by Validate, DryRun, and Execute. revision_id 是 +history-list 返回的
|
||||
// revision_id(minor id);edit_time 可选,传同一条 entry 的 edit_time 让服务端更快定位该版本。
|
||||
func historyRevertInput(runtime flagView) (map[string]interface{}, error) {
|
||||
rev := strings.TrimSpace(runtime.Str("revision-id"))
|
||||
if rev == "" {
|
||||
return nil, common.FlagErrorf("--revision-id is required (a revision_id from +history-list)")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"revision_id": rev,
|
||||
}
|
||||
if et := strings.TrimSpace(runtime.Str("edit-time")); et != "" {
|
||||
input["edit_time"] = et
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// historyRevertStatusInput builds the get_revert_status tool body.
|
||||
// Network-free; shared by Validate, DryRun, and Execute.
|
||||
func historyRevertStatusInput(runtime flagView) (map[string]interface{}, error) {
|
||||
tid := strings.TrimSpace(runtime.Str("task-id"))
|
||||
if tid == "" {
|
||||
return nil, common.FlagErrorf("--task-id is required")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"task_id": tid,
|
||||
}, nil
|
||||
}
|
||||
@@ -49,12 +49,6 @@ type objectCRUDSpec struct {
|
||||
// right nesting level.
|
||||
enhanceCreateInput func(rt flagView, input map[string]interface{})
|
||||
enhanceUpdateInput func(rt flagView, input map[string]interface{})
|
||||
// validateCreateInput, when set, runs after enhanceCreateInput to
|
||||
// enforce *cross-flag, create-only* constraints JSON Schema can't
|
||||
// express (e.g. pivot rejects --target-position vs --range when
|
||||
// both carry non-default values — they map to the same wire field
|
||||
// and conflicting values are ambiguous). Mirrors validateUpdateInput.
|
||||
validateCreateInput func(rt flagView, input map[string]interface{}) error
|
||||
// validateUpdateInput, when set, runs after enhanceUpdateInput to
|
||||
// enforce *cross-field, update-only* constraints JSON Schema can't
|
||||
// express (e.g. sparkline requires properties.sparklines[i] to
|
||||
@@ -196,11 +190,6 @@ func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec
|
||||
if spec.enhanceCreateInput != nil {
|
||||
spec.enhanceCreateInput(runtime, input)
|
||||
}
|
||||
if spec.validateCreateInput != nil {
|
||||
if err := spec.validateCreateInput(runtime, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := validateInputAgainstSchema(runtime, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -392,6 +381,9 @@ var pivotSpec = objectCRUDSpec{
|
||||
},
|
||||
createWarn: pivotPlacementWarn,
|
||||
enhanceCreateInput: func(rt flagView, input map[string]interface{}) {
|
||||
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
|
||||
input["target_position"] = v
|
||||
}
|
||||
props, _ := input["properties"].(map[string]interface{})
|
||||
if props == nil {
|
||||
return
|
||||
@@ -399,26 +391,10 @@ var pivotSpec = objectCRUDSpec{
|
||||
if v := strings.TrimSpace(rt.Str("source")); v != "" {
|
||||
props["source"] = v
|
||||
}
|
||||
// --target-position 与 --range 都映射到 properties.range;
|
||||
// --target-position 优先,未给(或为默认值 A1)时回落到 --range。
|
||||
// 互斥校验在 validateCreateInput 里做。
|
||||
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
|
||||
props["range"] = v
|
||||
} else if v := strings.TrimSpace(rt.Str("range")); v != "" {
|
||||
if v := strings.TrimSpace(rt.Str("range")); v != "" {
|
||||
props["range"] = v
|
||||
}
|
||||
},
|
||||
// --target-position 与 --range 落到同一 wire 字段(properties.range),
|
||||
// 同时给非默认值时无法判断意图——按 --target-sheet-id / --target-sheet-name
|
||||
// 的处理方式,CLI 端直接拒绝(优于静默丢弃其一)。
|
||||
validateCreateInput: func(rt flagView, _ map[string]interface{}) error {
|
||||
pos := strings.TrimSpace(rt.Str("target-position"))
|
||||
rng := strings.TrimSpace(rt.Str("range"))
|
||||
if pos != "" && pos != "A1" && rng != "" {
|
||||
return common.FlagErrorf("--target-position and --range are mutually exclusive (both map to properties.range; pass only one)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
var PivotCreate = newObjectCreateShortcut(pivotSpec)
|
||||
var PivotUpdate = newObjectUpdateShortcut(pivotSpec)
|
||||
|
||||
@@ -137,24 +137,25 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
|
||||
// covered separately in the +pivot-create empty-selector / mutex
|
||||
// tests below.
|
||||
{
|
||||
name: "+pivot-create with placement / source / target-position flags",
|
||||
name: "+pivot-create with placement / source / range flags",
|
||||
sc: PivotCreate,
|
||||
args: []string{
|
||||
"--url", testURL, "--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--range", "F1",
|
||||
"--target-position", "B5",
|
||||
},
|
||||
toolName: "manage_pivot_table_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "create",
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "create",
|
||||
"target_position": "B5",
|
||||
"properties": map[string]interface{}{
|
||||
"rows": []interface{}{map[string]interface{}{"field": "A"}},
|
||||
"source": "Sheet1!A1:F1000",
|
||||
// --target-position 映射到 properties.range。
|
||||
"range": "B5",
|
||||
"range": "F1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -506,55 +507,6 @@ func TestPivotCreate_SheetSelectorSemantics(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestPivotCreate_TargetPositionRangeMutex regresses the "--target-position
|
||||
// and --range cannot both be set" guardrail on +pivot-create. They map to
|
||||
// the same wire field (properties.range), so two non-default values are
|
||||
// ambiguous; the CLI rejects up front (mirrors the --target-sheet-id /
|
||||
// --target-sheet-name mutex). --target-position=A1 is the documented default
|
||||
// and is treated as "not set" — pairing it with --range still works.
|
||||
func TestPivotCreate_TargetPositionRangeMutex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("both non-default values rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--target-position", "B5",
|
||||
"--range", "F1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to reject --target-position with --range; stderr=%s", stderr)
|
||||
}
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "mutually exclusive") {
|
||||
t.Errorf("expected error to say 'mutually exclusive'; got=%s|%v", stderr, err)
|
||||
}
|
||||
if !strings.Contains(combined, "--target-position") || !strings.Contains(combined, "--range") {
|
||||
t.Errorf("expected error to quote both --target-position and --range; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default A1 with --range is accepted (range wins)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--target-position", "A1",
|
||||
"--range", "F1",
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_pivot_table_object")
|
||||
props, _ := input["properties"].(map[string]interface{})
|
||||
if got, _ := props["range"].(string); got != "F1" {
|
||||
t.Errorf("properties.range = %q, want %q", got, "F1")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPivotCreate_SchemaValidates exercises the schema-driven
|
||||
// validator wired into objectCreateInput. The pivot create schema
|
||||
// doesn't constrain rows/columns/values to be present (the backend
|
||||
|
||||
@@ -5,6 +5,8 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -162,7 +164,12 @@ var CsvGet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !runtime.Bool("include-row-prefix") {
|
||||
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"):
|
||||
out = stripRowPrefixFromCsvOutput(out)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
@@ -212,6 +219,141 @@ 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 {
|
||||
@@ -235,6 +377,89 @@ 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,6 +63,20 @@ 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) {
|
||||
@@ -165,3 +179,113 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,72 +0,0 @@
|
||||
// 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"])
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,14 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -141,28 +145,6 @@ 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) {
|
||||
@@ -306,7 +288,7 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{"--title", "MySheet"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1 (no values)", len(calls))
|
||||
t.Fatalf("api calls = %d, want 1 (no headers/data)", len(calls))
|
||||
}
|
||||
c := calls[0].(map[string]interface{})
|
||||
if c["url"] != "/open-apis/sheets/v3/spreadsheets" {
|
||||
@@ -318,11 +300,12 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with values → 2-step plan", func(t *testing.T) {
|
||||
t.Run("with headers and data → 2-step plan", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95],["bob",88]]`,
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95],["bob",88]]`,
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
|
||||
@@ -334,88 +317,7 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
body, _ := fill["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
if input["range"] != "A1:B3" {
|
||||
t.Errorf("fill range = %v, want A1:B3 (3 rows × 2 cols)", input["range"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with styles merges into set_cell_range cells", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","font_weight":"bold","background_color":"#f5f5f5"},{"range":"B1","number_format":"0","border_styles":{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}},{"range":"B2","font_color":"#0f7b0f"}]}]}`,
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
|
||||
}
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
if len(cells) != 2 {
|
||||
t.Fatalf("cells rows = %#v, want 2", input["cells"])
|
||||
}
|
||||
headerRow, _ := cells[0].([]interface{})
|
||||
firstHeader, _ := headerRow[0].(map[string]interface{})
|
||||
firstStyle, _ := firstHeader["cell_styles"].(map[string]interface{})
|
||||
if firstStyle["font_weight"] != "bold" || firstStyle["background_color"] != "#f5f5f5" {
|
||||
t.Errorf("first header style = %#v, want bold + background", firstStyle)
|
||||
}
|
||||
secondHeader, _ := headerRow[1].(map[string]interface{})
|
||||
if secondHeader["border_styles"] == nil {
|
||||
t.Errorf("second header missing border_styles: %#v", secondHeader)
|
||||
}
|
||||
secondStyle, _ := secondHeader["cell_styles"].(map[string]interface{})
|
||||
if secondStyle["number_format"] != "0" {
|
||||
t.Errorf("second header number_format = %#v, want 0", secondStyle)
|
||||
}
|
||||
dataRow, _ := cells[1].([]interface{})
|
||||
firstData, _ := dataRow[0].(map[string]interface{})
|
||||
if _, ok := firstData["cell_styles"]; ok {
|
||||
t.Errorf("null style should leave first data cell unstyled: %#v", firstData)
|
||||
}
|
||||
secondData, _ := dataRow[1].(map[string]interface{})
|
||||
secondDataStyle, _ := secondData["cell_styles"].(map[string]interface{})
|
||||
if secondDataStyle["font_color"] != "#0f7b0f" {
|
||||
t.Errorf("second data style = %#v, want font color", secondDataStyle)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cell style range can cover the whole initial range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B2","horizontal_alignment":"center"}]}]}`,
|
||||
})
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
raw, _ := json.Marshal(input["cells"])
|
||||
if got := strings.Count(string(raw), "horizontal_alignment"); got != 4 {
|
||||
t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", got, raw)
|
||||
}
|
||||
})
|
||||
t.Run("overlapping cell_styles deep-merge fields, no cross-cell pollution", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "X",
|
||||
"--values", `[["a","b"]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B1","font_weight":"bold"},{"range":"B1","font_color":"#ff0000"}]}]}`,
|
||||
})
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
row0, _ := cells[0].([]interface{})
|
||||
// B1 hit by both ops → must keep BOTH font_weight (op1) and font_color (op2).
|
||||
b1, _ := row0[1].(map[string]interface{})
|
||||
b1s, _ := b1["cell_styles"].(map[string]interface{})
|
||||
if b1s["font_weight"] != "bold" || b1s["font_color"] != "#ff0000" {
|
||||
t.Errorf("B1 should deep-merge both ops, got %#v", b1s)
|
||||
}
|
||||
// A1 hit only by op1 → must NOT be polluted by op2's font_color (shared submap).
|
||||
a1, _ := row0[0].(map[string]interface{})
|
||||
a1s, _ := a1["cell_styles"].(map[string]interface{})
|
||||
if a1s["font_color"] != nil {
|
||||
t.Errorf("A1 must not be polluted by op2, got %#v", a1s)
|
||||
t.Errorf("fill range = %v, want A1:B3 (1 header + 2 data rows × 2 cols)", input["range"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -428,18 +330,8 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"headers not array", []string{"--title", "X", "--headers", `"abc"`}, "must be a JSON array"},
|
||||
{"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"},
|
||||
{"styles not object", []string{"--title", "X", "--styles", `"bold"`}, `shaped as {"styles":[...]}`},
|
||||
{"styles missing array", []string{"--title", "X", "--styles", `{"value":"x"}`}, "--styles.styles is required"},
|
||||
{"styles item missing groups", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","value":"x"}]}`}, "must include at least one of cell_styles/row_sizes/col_sizes/cell_merges"},
|
||||
{"cell styles must be array", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":{"range":"A1","font_weight":"bold"}}]}`}, "cell_styles must be an array"},
|
||||
{"cell style needs range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"font_weight":"bold"}]}]}`}, "range is required"},
|
||||
{"nested cell_styles rejected", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","cell_styles":{"font_weight":"bold"}}]}]}`}, "put style fields directly"},
|
||||
{"row size needs row range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","row_sizes":[{"range":"A1","type":"pixel","size":20}]}]}`}, "must use row numbers"},
|
||||
{"col size needs pixel size", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","col_sizes":[{"range":"A:A","type":"pixel"}]}]}`}, "requires size"},
|
||||
{"border bad style enum", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"bottom":{"style":"NONSENSE"}}}]}]}`}, `style "NONSENSE" is invalid`},
|
||||
{"border invalid side", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"diagonal":{"style":"solid"}}}]}]}`}, "not a valid side"},
|
||||
{"border bad weight", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"top":{"weight":"xxl"}}}]}]}`}, `weight "xxl" is invalid`},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -452,21 +344,21 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// TestWorkbookExport_DryRun checks the 2-or-3 step plan depending on
|
||||
// --output-path. The order should be: POST → GET (poll) → optional GET
|
||||
// (download).
|
||||
func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("xlsx create-task body pins type=sheet", func(t *testing.T) {
|
||||
t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1 (create export task)", len(calls))
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls))
|
||||
}
|
||||
create := calls[0].(map[string]interface{})
|
||||
if create["url"] != "/open-apis/drive/v1/export_tasks" {
|
||||
t.Errorf("url = %v", create["url"])
|
||||
t.Errorf("first url = %v", create["url"])
|
||||
}
|
||||
body, _ := create["body"].(map[string]interface{})
|
||||
if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken {
|
||||
@@ -474,18 +366,22 @@ func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csv includes sub_id from --sheet-id", func(t *testing.T) {
|
||||
t.Run("csv → 3 steps, with sub_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) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1", len(calls))
|
||||
if len(calls) != 3 {
|
||||
t.Fatalf("api calls = %d, want 3", len(calls))
|
||||
}
|
||||
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
|
||||
if body["type"] != "sheet" || body["sub_id"] != "sh1" {
|
||||
t.Errorf("csv export body = %#v (want type=sheet, sub_id=sh1)", body)
|
||||
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"])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -500,6 +396,92 @@ func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkbookExportDownloadErrorClassification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("preserves typed request errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := errs.NewAPIError(errs.SubtypeServerError, "typed upstream").WithCode(123)
|
||||
got := sheetsDownloadRequestError(in)
|
||||
if got != in {
|
||||
t.Fatalf("typed error was not preserved: got %T %v", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wraps raw request errors as network transport", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := sheetsDownloadRequestError(errors.New("dial refused"))
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", got, got)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
wantCategory errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetryable bool
|
||||
}{
|
||||
{
|
||||
name: "5xx is retryable network server error",
|
||||
status: http.StatusBadGateway,
|
||||
wantCategory: errs.CategoryNetwork,
|
||||
wantSubtype: errs.SubtypeNetworkServer,
|
||||
wantRetryable: true,
|
||||
},
|
||||
{
|
||||
name: "404 is API not found",
|
||||
status: http.StatusNotFound,
|
||||
wantCategory: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeNotFound,
|
||||
},
|
||||
{
|
||||
name: "429 is retryable API rate limit",
|
||||
status: http.StatusTooManyRequests,
|
||||
wantCategory: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeRateLimit,
|
||||
wantRetryable: true,
|
||||
},
|
||||
{
|
||||
name: "other 4xx is API unknown",
|
||||
status: http.StatusForbidden,
|
||||
wantCategory: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeUnknown,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := sheetsDownloadHTTPStatusError(&larkcore.ApiResp{
|
||||
StatusCode: tt.status,
|
||||
RawBody: []byte("body"),
|
||||
Header: http.Header{larkcore.HttpHeaderKeyLogId: []string{"log123"}},
|
||||
})
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", got, got)
|
||||
}
|
||||
if p.Category != tt.wantCategory || p.Subtype != tt.wantSubtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, tt.wantCategory, tt.wantSubtype)
|
||||
}
|
||||
if p.Code != tt.status {
|
||||
t.Fatalf("code = %d, want %d", p.Code, tt.status)
|
||||
}
|
||||
if p.LogID != "log123" {
|
||||
t.Fatalf("log_id = %q, want log123", p.LogID)
|
||||
}
|
||||
if p.Retryable != tt.wantRetryable {
|
||||
t.Fatalf("retryable = %v, want %v", p.Retryable, tt.wantRetryable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertInputEquals compares the decoded tool input map against the wanted
|
||||
// fields. Extra fields in `got` are allowed (defaults, optional fields);
|
||||
// every key in `want` must match exactly.
|
||||
|
||||
@@ -197,12 +197,12 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet. A cell whose
|
||||
// text starts with = is evaluated as a formula; use +cells-set for styles / notes / images.
|
||||
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet, only writing
|
||||
// plain values. Use +cells-set for anything richer (formula / style / note).
|
||||
var CsvPut = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+csv-put",
|
||||
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (values or formulas: a leading = is evaluated as a formula; no styles / comments; auto-expands sheet if needed).",
|
||||
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (plain values only, auto-expands sheet if needed).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -428,44 +427,6 @@ func TestCellsSet_RequiresJSONArray(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSet_RejectsUnsupportedMentionType pins the mention_type enum in
|
||||
// data/flag-schemas.json (synced from the upstream tool schema): a rich_text
|
||||
// mention whose mention_type is outside MENTION_FILE_TYPE (here 6 = cloud
|
||||
// shared folder) is rejected by the schema validator at flag-parse time,
|
||||
// before it can reach the server and blow up pb serialization
|
||||
// ("mentionFileInfo.fileType: enum value expected").
|
||||
func TestCellsSet_RejectsUnsupportedMentionType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cells := `[[{"rich_text":[{"type":"mention","text":"x","mention_type":6,"mention_token":"t"}]}]]`
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1", "--cells", cells, "--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, "mention_type") || !strings.Contains(combined, "not in enum") {
|
||||
t.Errorf("expected mention_type enum guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSet_AllowsValidMentionTypes confirms the guard lets through a
|
||||
// user @mention (mention_type 0) and a render-supported file type (22 = DOCX).
|
||||
func TestCellsSet_AllowsValidMentionTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, mt := range []int{0, 22} {
|
||||
cells := fmt.Sprintf(`[[{"rich_text":[{"type":"mention","text":"x","mention_type":%d,"mention_token":"t"}]}]]`, mt)
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1", "--cells", cells, "--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("mention_type %d: unexpected error: stdout=%s stderr=%s err=%v", mt, stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSetImage_DryRun verifies the 2-step plan (upload + embed) is
|
||||
// rendered, including the parent_type=sheet_image upload metadata.
|
||||
func TestCellsSetImage_DryRun(t *testing.T) {
|
||||
|
||||
@@ -38,11 +38,8 @@ func shortcutList() []common.Shortcut {
|
||||
SheetHide,
|
||||
SheetUnhide,
|
||||
SheetSetTabColor,
|
||||
SheetShowGridline,
|
||||
SheetHideGridline,
|
||||
WorkbookCreate,
|
||||
WorkbookExport,
|
||||
WorkbookImport,
|
||||
|
||||
// lark_sheet_sheet_structure
|
||||
SheetInfo,
|
||||
@@ -59,7 +56,6 @@ func shortcutList() []common.Shortcut {
|
||||
CellsGet,
|
||||
CsvGet,
|
||||
DropdownGet,
|
||||
TableGet,
|
||||
|
||||
// lark_sheet_search_replace
|
||||
CellsSearch,
|
||||
@@ -71,7 +67,6 @@ func shortcutList() []common.Shortcut {
|
||||
CellsSetImage,
|
||||
CsvPut,
|
||||
DropdownSet,
|
||||
TablePut,
|
||||
|
||||
// lark_sheet_range_operations
|
||||
CellsClear,
|
||||
@@ -108,10 +103,5 @@ func shortcutList() []common.Shortcut {
|
||||
CellsBatchClear,
|
||||
DropdownUpdate,
|
||||
DropdownDelete,
|
||||
|
||||
// lark_sheet_history
|
||||
HistoryList,
|
||||
HistoryRevert,
|
||||
HistoryRevertStatus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ func Shortcuts() []common.Shortcut {
|
||||
VCRecording,
|
||||
VCMeetingJoin,
|
||||
VCMeetingLeave,
|
||||
VCMeetingListActive,
|
||||
VCMeetingEvents,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ var VCMeetingEvents = common.Shortcut{
|
||||
Description: "List bot meeting events by meeting ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-id", Required: true, Desc: "meeting ID to query"},
|
||||
@@ -156,6 +156,9 @@ func validateMeetingEventsMeetingID(meetingID string) error {
|
||||
if meetingID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-id is required").WithParam("--meeting-id")
|
||||
}
|
||||
if validMeetingNumber(meetingID) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-id must be a long meeting_id, not a 9-digit meeting number; use +meeting-join or +meeting-list-active to get meeting_id").WithParam("--meeting-id")
|
||||
}
|
||||
value, err := strconv.ParseInt(meetingID, 10, 64)
|
||||
if err != nil || value <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-id must be a positive integer, got %q", meetingID).WithParam("--meeting-id")
|
||||
|
||||
@@ -262,6 +262,26 @@ func TestMeetingEvents_Validation_InvalidMeetingID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_Validation_RejectsMeetingNumber(t *testing.T) {
|
||||
runtime := newMeetingEventsRuntime()
|
||||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "732067044")
|
||||
|
||||
err := VCMeetingEvents.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for 9-digit meeting number")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a 9-digit meeting number") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--meeting-id" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--meeting-id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_Validation_InvalidTimeRange(t *testing.T) {
|
||||
runtime := newMeetingEventsRuntime()
|
||||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||||
@@ -818,7 +838,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
|
||||
for _, shortcut := range got {
|
||||
commands = append(commands, shortcut.Command)
|
||||
}
|
||||
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-events"}
|
||||
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"}
|
||||
if !reflect.DeepEqual(commands, want) {
|
||||
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ var VCMeetingJoin = common.Shortcut{
|
||||
Description: "Join a meeting by meeting number (bot join)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"vc:meeting.bot.join:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-number", Required: true, Desc: "meeting number to join"},
|
||||
|
||||
@@ -20,7 +20,7 @@ var VCMeetingLeave = common.Shortcut{
|
||||
Description: "Leave a meeting by meeting ID",
|
||||
Risk: "write",
|
||||
Scopes: []string{"vc:meeting.bot.join:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-id", Required: true, Desc: "meeting ID to leave"},
|
||||
|
||||
121
shortcuts/vc/vc_meeting_list_active.go
Normal file
121
shortcuts/vc/vc_meeting_list_active.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const vcMeetingListActiveAPIPath = "/open-apis/vc/v1/bots/user_active_meeting"
|
||||
|
||||
// VCMeetingListActive lists meetings the current or target user is actively in.
|
||||
var VCMeetingListActive = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+meeting-list-active",
|
||||
Description: "List active meetings for the current identity or target user",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Desc: "target user ID when using bot identity"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMeetingListActiveUserID(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params, err := buildMeetingListActiveParams(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
dryRun := common.NewDryRunAPI().GET(vcMeetingListActiveAPIPath)
|
||||
if len(params) > 0 {
|
||||
dryRun.Params(params)
|
||||
}
|
||||
return dryRun
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
params, err := buildMeetingListActiveParams(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, vcMeetingListActiveAPIPath, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
meetings := common.GetSlice(data, "meetings")
|
||||
runtime.OutFormat(data, &output.Meta{Count: len(meetings)}, func(w io.Writer) {
|
||||
if len(meetings) == 0 {
|
||||
fmt.Fprintln(w, "No active meetings.")
|
||||
return
|
||||
}
|
||||
displayedMeetings := 0
|
||||
for _, raw := range meetings {
|
||||
meeting, _ := raw.(map[string]interface{})
|
||||
if meeting == nil {
|
||||
continue
|
||||
}
|
||||
if displayedMeetings > 0 {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
displayedMeetings++
|
||||
title := common.GetString(meeting, "meeting_title")
|
||||
if title == "" {
|
||||
title = "Untitled meeting"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\n", title)
|
||||
if id := common.GetString(meeting, "meeting_id"); id != "" {
|
||||
fmt.Fprintf(w, " Meeting ID: %s\n", id)
|
||||
}
|
||||
if no := common.GetString(meeting, "meeting_no"); no != "" {
|
||||
fmt.Fprintf(w, " Meeting No: %s\n", no)
|
||||
}
|
||||
}
|
||||
if displayedMeetings > 1 {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "Multiple active meetings found. Ask the user to choose one meeting_id before calling +meeting-events.")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// validateMeetingListActiveUserID validates the target user only for bot identity.
|
||||
func validateMeetingListActiveUserID(runtime *common.RuntimeContext) error {
|
||||
if !runtime.IsBot() {
|
||||
return nil
|
||||
}
|
||||
userID := strings.TrimSpace(runtime.Str("user-id"))
|
||||
if userID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id is required when --as bot").WithParam("--user-id")
|
||||
}
|
||||
if _, err := common.ValidateUserIDTyped("--user-id", userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildMeetingListActiveParams builds the query params for active meeting lookup.
|
||||
func buildMeetingListActiveParams(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
if err := validateMeetingListActiveUserID(runtime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := map[string]interface{}{}
|
||||
if runtime.IsBot() {
|
||||
userID := strings.TrimSpace(runtime.Str("user-id"))
|
||||
params["user_id"] = userID
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -589,6 +591,335 @@ func TestMeetingLeave_Execute_APIError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_DryRun_UserIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/vc/v1/bots/user_active_meeting") {
|
||||
t.Errorf("dry-run should include API path, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "user_id") {
|
||||
t.Errorf("user identity should not send user_id by default, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_ScopeMatchesEventReadPermission(t *testing.T) {
|
||||
if len(VCMeetingListActive.Scopes) != 1 || VCMeetingListActive.Scopes[0] != "vc:meeting.meetingevent:read" {
|
||||
t.Fatalf("scopes = %#v, want [vc:meeting.meetingevent:read]", VCMeetingListActive.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_DryRun_UserIdentityIgnoresUserID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--dry-run", "--as", "user", "--user-id", "not-open-id",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "user_id") {
|
||||
t.Errorf("user identity should not send user_id, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_Execute_UserIdentityIgnoresInvalidUserID(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
var gotUserID string
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/bots/user_active_meeting",
|
||||
OnMatch: func(req *http.Request) {
|
||||
gotUserID = req.URL.Query().Get("user_id")
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"meetings": []interface{}{}},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--as", "user", "--user-id", "not-open-id", "--format", "json",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotUserID != "" {
|
||||
t.Fatalf("user identity should not send user_id, got %q", gotUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_Validate_BotRequiresUserID(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{"+meeting-list-active", "--as", "bot"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --as bot omits --user-id")
|
||||
}
|
||||
assertMeetingListActiveUserIDValidationError(t, err)
|
||||
}
|
||||
|
||||
func TestMeetingListActive_Validate_UserIDOpenIDFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--as", "bot", "--user-id", "300",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-open_id user-id")
|
||||
}
|
||||
assertMeetingListActiveUserIDValidationError(t, err)
|
||||
}
|
||||
|
||||
func TestMeetingListActive_Execute_BotPassesUserID(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
var gotUserID string
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/bots/user_active_meeting",
|
||||
OnMatch: func(req *http.Request) {
|
||||
gotUserID = req.URL.Query().Get("user_id")
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"meetings": []interface{}{
|
||||
map[string]interface{}{
|
||||
"meeting_id": "9001",
|
||||
"meeting_no": "123456789",
|
||||
"meeting_title": "Standup",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--user-id", "ou_300",
|
||||
"--format", "json", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotUserID != "ou_300" {
|
||||
t.Fatalf("user_id query = %q, want ou_300", gotUserID)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse stdout: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
if len(meetings) != 1 {
|
||||
t.Fatalf("meetings = %d, want 1 (envelope: %s)", len(meetings), stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_DryRun_BotValidationErrorEnvelope(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "+meeting-list-active"}
|
||||
cmd.Flags().String("user-id", "", "")
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, defaultConfig(), core.AsBot)
|
||||
|
||||
dry := VCMeetingListActive.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
raw, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal dry-run output: %v", err)
|
||||
}
|
||||
got := string(raw)
|
||||
if !strings.Contains(got, "--user-id") {
|
||||
t.Fatalf("dry-run error = %q, want user-id validation", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_DryRun_BotSendsUserID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--dry-run", "--as", "bot", "--user-id", "ou_300",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "user_id") || !strings.Contains(stdout.String(), "ou_300") {
|
||||
t.Fatalf("dry-run should include user_id=ou_300, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_Execute_ValidationError(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "+meeting-list-active"}
|
||||
cmd.Flags().String("user-id", "", "")
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, defaultConfig(), core.AsBot)
|
||||
|
||||
err := VCMeetingListActive.Execute(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
assertMeetingListActiveUserIDValidationError(t, err)
|
||||
}
|
||||
|
||||
func TestMeetingListActive_ExecutePretty_Empty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/bots/user_active_meeting",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--format", "pretty", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No active meetings.") {
|
||||
t.Fatalf("pretty output = %q, want empty-state message", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_ExecutePretty_SingleMeetingNoSelectionPrompt(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/bots/user_active_meeting",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"meetings": []interface{}{
|
||||
map[string]interface{}{
|
||||
"meeting_id": "9001",
|
||||
"meeting_no": "123456789",
|
||||
"meeting_title": "Standup",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--format", "pretty", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"Standup", "Meeting ID: 9001", "Meeting No: 123456789"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "Multiple active meetings found") {
|
||||
t.Fatalf("single meeting should not show selection prompt: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_ExecutePretty_MultipleMeetings(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/bots/user_active_meeting",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"meetings": []interface{}{
|
||||
map[string]interface{}{
|
||||
"meeting_id": "9001",
|
||||
"meeting_no": "123456789",
|
||||
"meeting_title": "Standup",
|
||||
},
|
||||
"ignored",
|
||||
map[string]interface{}{
|
||||
"meeting_id": "9002",
|
||||
"meeting_no": "987654321",
|
||||
"meeting_title": "Planning",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"meeting_id": "9003",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--format", "pretty", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Standup",
|
||||
"Meeting ID: 9001",
|
||||
"Meeting No: 123456789",
|
||||
"Planning",
|
||||
"Meeting ID: 9002",
|
||||
"Meeting No: 987654321",
|
||||
"Untitled meeting",
|
||||
"Meeting ID: 9003",
|
||||
"Multiple active meetings found. Ask the user to choose one meeting_id before calling +meeting-events.",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_Execute_APIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/bots/user_active_meeting",
|
||||
Body: map[string]interface{}{"code": 121005, "msg": "no permission"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--format", "json", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error")
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryAuthorization {
|
||||
t.Fatalf("error problem = (%+v, %t), want authorization problem", p, ok)
|
||||
} else if p.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Fatalf("error subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
|
||||
} else if p.Code != 121005 {
|
||||
t.Fatalf("error code = %d, want 121005", p.Code)
|
||||
}
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMeetingListActiveUserIDValidationError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
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 ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--user-id" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--user-id")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed error lock assertions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "飞书云文档(Docx / Wiki 文档,v2 API):读取和编辑
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli docs --api-version v2 --help; lark-cli docs +create --api-version v2 --help; lark-cli docs +fetch --api-version v2 --help; lark-cli docs +update --api-version v2 --help"
|
||||
cliHelp: "lark-cli docs --api-version v2 --help; lark-cli docs +create --api-version v2 --help; lark-cli docs +fetch --api-version v2 --help; lark-cli docs +update --api-version v2 --help; lark-cli docs resource-download --help; lark-cli docs resource-update --help; lark-cli docs resource-delete --help"
|
||||
---
|
||||
|
||||
# docs (v2)
|
||||
@@ -45,6 +45,8 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
|
||||
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
|
||||
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
|
||||
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs resource-download/resource-update/resource-delete --type cover`
|
||||
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
|
||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
|
||||
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
|
||||
@@ -71,6 +73,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
||||
| [`resource-download` / `resource-update` / `resource-delete`](references/lark-doc-resource-cover.md) | Download, update, or delete a Docx cover image resource with `--type cover` |
|
||||
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |
|
||||
|
||||
## 不在本 Skill 范围
|
||||
|
||||
@@ -23,7 +23,7 @@ lark-cli docs +create --api-version v2 --parent-token fldcnXXXX --content '<titl
|
||||
# 创建到个人知识库(XML)
|
||||
lark-cli docs +create --api-version v2 --parent-position my_library --content '<title>标题</title><p>内容</p>'
|
||||
|
||||
# 仅当用户明确要求时才使用 Markdown
|
||||
# 仅当用户明确要求时才使用 Markdown;文档标题必须是开头唯一的一级标题,正文从二级标题开始
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项目计划\n\n## 目标\n\n- 目标 1\n- 目标 2'
|
||||
```
|
||||
|
||||
@@ -72,7 +72,7 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- 文档标题从内容中自动提取(XML `<title>` 或 Markdown `#`),不要在内容开头重复写标题
|
||||
- 文档标题从内容中自动提取:XML 使用 `<title>`;Markdown 使用文档开头唯一的一级标题(`# 标题`),正文从 `##` 开始。不要在内容开头重复写标题,也不要在 Markdown 正文中使用多个一级标题。
|
||||
- **创建较长的文档时只建骨架**:`--content` 仅传标题 + 各级 heading + 简短占位摘要;正文留给后续 `block_insert_after --block-id <章节标题 block_id>` 分段追加。一次性塞超长 `--content` 既容易触发参数限制,调试也更难。
|
||||
- **视觉丰富度**:必须遵循 [`lark-doc-style.md`](style/lark-doc-style.md) 中的样式指南,主动使用结构化 block 丰富文档
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
- `<img>` / `<source>` 带 `url` 时,直接用该 URL 下载即可(普通 HTTP GET),无需走 shortcut。
|
||||
- 没有 `url`、或只想预览 → `docs +media-preview --token <token> --output ./preview_media`
|
||||
- 明确下载,或目标是 `<whiteboard>`(画板只能走 shortcut) → `docs +media-download --token <token> --output ./downloaded_media`
|
||||
- 文档封面图不是正文素材;下载/更新/删除封面图 → `docs resource-download/resource-update/resource-delete --type cover`
|
||||
|
||||
## 嵌入电子表格 / 多维表格
|
||||
|
||||
@@ -134,4 +135,5 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
- [lark-doc-create](lark-doc-create.md) — 创建文档
|
||||
- [lark-doc-update](lark-doc-update.md) — 更新文档
|
||||
- [lark-doc-media-preview](lark-doc-media-preview.md) — 预览素材
|
||||
- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图
|
||||
- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图
|
||||
- [lark-doc-resource-cover](lark-doc-resource-cover.md) — 读取、更新、删除文档封面图
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用。
|
||||
|
||||
## 创建文档标题
|
||||
|
||||
使用 `docs +create --doc-format markdown` 创建文档时,文档标题必须写成内容开头唯一的一级标题:`# 标题`。正文标题从 `##` 开始,不要使用多个一级标题;否则标题可能无法被提取并显示为 `Untitled`。
|
||||
|
||||
## 转义规则
|
||||
|
||||
> **⚠️ 当文本中包含以下字符且不想触发 Markdown 语法时**,需用 `\` 前缀转义。转义分为**无条件转义**(行内任意位置生效)和**位置敏感转义**(仅特定位置才需要)两类。
|
||||
@@ -73,4 +77,4 @@ Markdown 格式支持通过 URL 插入网络图片,图片将自动从 HTTP 下
|
||||
|
||||
## 参考
|
||||
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
|
||||
70
skills/lark-doc/references/lark-doc-resource-cover.md
Normal file
70
skills/lark-doc/references/lark-doc-resource-cover.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# docs resource-*(Docx 封面图资源)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
Docx 封面图不是正文里的 `<img token="...">` 素材块。读取、更新、删除文档封面图时,使用 `docs resource-download/resource-update/resource-delete --type cover`,不要使用 `+media-insert` 或 `+media-download --token <cover.token>` 让用户手动拼步骤。
|
||||
|
||||
## 选择规则
|
||||
|
||||
- 用户要下载文档封面图:`docs resource-download --type cover`
|
||||
- 用户要设置/替换文档封面图:`docs resource-update --type cover`
|
||||
- 用户要删除文档封面图:`docs resource-delete --type cover`
|
||||
- 用户要下载正文图片、附件、画板缩略图:继续使用 [`docs +media-download`](lark-doc-media-download.md)
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 下载封面图。CLI 会先读取 document.cover.token,再下载图片内容并保存到本地。
|
||||
lark-cli docs resource-download --doc doxcnXXX --type cover --output ./cover
|
||||
|
||||
# 使用本地文件更新封面图。
|
||||
lark-cli docs resource-update --doc doxcnXXX --type cover --file ./cover.png
|
||||
|
||||
# 使用剪切板图片更新封面图。
|
||||
lark-cli docs resource-update --doc doxcnXXX --type cover --from-clipboard
|
||||
|
||||
# 使用 HTTPS URL 更新封面图。CLI 会先下载 URL 内容,再上传并写入 cover.token。
|
||||
lark-cli docs resource-update --doc doxcnXXX --type cover --url "https://example.com/cover.png"
|
||||
|
||||
# 可选:设置封面图裁切偏移。
|
||||
lark-cli docs resource-update --doc doxcnXXX --type cover --file ./cover.png --offset-ratio-x 0.2 --offset-ratio-y 0.8
|
||||
|
||||
# 删除封面图;当文档本来没有封面图时也成功返回。
|
||||
lark-cli docs resource-delete --doc doxcnXXX --type cover
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 命令 | 参数 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| all | `--doc <id>` | 是 | 文档 ID、docx URL,或可解析为 docx 的 wiki URL |
|
||||
| all | `--type cover` | 否 | 当前只支持 `cover`;默认值也是 `cover` |
|
||||
| download | `--output <path>` | 是 | 本地保存路径;不带扩展名会根据响应类型自动补全 |
|
||||
| download | `--overwrite` | 否 | 覆盖已存在的输出文件 |
|
||||
| update | `--file <path>` | 三选一 | 磁盘上的真实图片文件;大于 20MiB 自动使用分片上传 |
|
||||
| update | `--from-clipboard` | 三选一 | 从系统剪切板读取图片 |
|
||||
| update | `--url <https-url>` | 三选一 | 从 HTTPS URL 下载图片后上传 |
|
||||
| update | `--offset-ratio-x <number>` | 否 | 视图相对原图中心的横向偏移比例:水平偏移 px / 原图宽度 px;0 为居中,正数向右,负数向左 |
|
||||
| update | `--offset-ratio-y <number>` | 否 | 视图相对原图中心的纵向偏移比例:垂直偏移 px / 原图高度 px;0 为居中,正数向上,负数向下 |
|
||||
|
||||
## 输出契约
|
||||
|
||||
- `resource-download` 成功时 stdout JSON 的 `data` 包含 `document_id`、`type`、`saved_path`、`size_bytes`、`content_type`、`cover.token`。如果文档没有封面图,命令失败退出,错误包含 `document has no cover` 和脱敏 `document_id`,不会创建输出文件。
|
||||
- `resource-update` 成功时 stdout JSON 的 `data` 包含完整 `file_token` 和 `cover.token`;stderr 只打印脱敏 token。
|
||||
- `resource-delete` 成功时 stdout JSON 的 `data.deleted` 表示本次是否真的发起删除,`data.already_empty` 表示删除前是否没有封面图。空封面图是幂等成功,不报错。
|
||||
|
||||
## URL 来源安全边界
|
||||
|
||||
`resource-update --url` 只用于下载公开 HTTPS 图片:
|
||||
|
||||
- 只允许 `https://`,拒绝 HTTP、空 host 和 URL userinfo。
|
||||
- 拒绝解析到 private、loopback、link-local、multicast、unspecified 地址的 host。
|
||||
- 最多跟随 3 次跳转,每次跳转都重新校验 URL。
|
||||
- 响应 `Content-Type` 只允许 `image/png`、`image/jpeg`、`image/gif`、`image/webp`。
|
||||
- 响应体最大 20MiB。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-doc-media-download](lark-doc-media-download.md) — 下载正文素材或画板缩略图
|
||||
- [lark-doc-media-insert](lark-doc-media-insert.md) — 在正文插入图片/文件
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -113,6 +113,8 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace
|
||||
--content '<p>替换后的段落内容</p>'
|
||||
```
|
||||
|
||||
> `block_replace` 由服务端执行整块替换,目标 block 的 ID 不保证在替换后继续可用。后续如果还要在替换后的块附近继续 `block_insert_after`、`range` 或其他 block 级操作,先重新 `docs +fetch --detail with-ids` 获取最新 block ID,不要复用旧 ID。
|
||||
|
||||
### block_delete — 删除指定 block
|
||||
|
||||
```bash
|
||||
@@ -234,6 +236,7 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
|
||||
- **str_replace 的 replacement 支持富文本**:可以用行内标签 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本
|
||||
- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
|
||||
- **block_replace 后重新获取 ID**:`block_replace` 成功后旧 block ID 不保证继续可用;继续做相邻块操作前,重新 `docs +fetch --detail with-ids`
|
||||
- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
|
||||
- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全:
|
||||
1. 用 `block_insert_after` 在目标位置插入新的富文本结构
|
||||
|
||||
@@ -10,6 +10,10 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
| `<title>` | 文档标题(每篇唯一)| `align` |
|
||||
| `<checkbox>` | 待办项| `done="true"\|"false"` |
|
||||
|
||||
## 创建文档标题
|
||||
|
||||
使用 `docs +create` 创建 XML 文档时,文档标题必须写成 `<title>标题</title>`,且每篇文档只写一个 `<title>`。
|
||||
|
||||
## 容器标签
|
||||
|标签|说明|关键属性|
|
||||
|-|-|-|
|
||||
@@ -77,6 +81,10 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
</ul>
|
||||
```
|
||||
|
||||
## 代码块
|
||||
- 代码块必须写成 `<pre lang="xxx" caption="可选说明"><code>代码内容</code></pre>`。
|
||||
- 不要将代码文本直接放在 `<pre>` 下;应放在内层 `<code>` 中。
|
||||
|
||||
|
||||
## 用户名写入规则
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ metadata:
|
||||
## 快速决策
|
||||
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--mine`,实为 owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
|
||||
- 用户给出 doubao.com 的云空间资源 URL/token,或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,仍按资源类型、URL 路径和 token 路由到本 skill;不要因为域名不是飞书而回退到 WebFetch。
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
@@ -110,7 +110,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|----------|
|
||||
| [`+search`](references/lark-drive-search.md) | 搜索文档、Wiki、表格、文件夹等云空间对象;支持 `--edited-since`、`--mine`、`--doc-types` 等扁平 flag。 |
|
||||
| [`+search`](references/lark-drive-search.md) | 搜索文档、Wiki、表格、文件夹等云空间对象;支持 `--edited-since`、`--created-by-me`、`--mine`、`--doc-types` 等扁平 flag;区分 original creator 与 owner 语义。 |
|
||||
| [`+upload`](references/lark-drive-upload.md) | 上传本地文件到 Drive 文件夹或 wiki 节点。 |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | 新建 Drive 文件夹,支持父文件夹与 bot 创建后自动授权。 |
|
||||
| [`+download`](references/lark-drive-download.md) | 下载 Drive 文件到本地。 |
|
||||
|
||||
@@ -76,6 +76,14 @@ lark-cli drive +export \
|
||||
--file-extension base \
|
||||
--output-dir ./exports
|
||||
|
||||
# 导出多维表格结构为 .base 快照(仅导出表结构,不导出记录数据)
|
||||
lark-cli drive +export \
|
||||
--token "<BITABLE_TOKEN>" \
|
||||
--doc-type bitable \
|
||||
--file-extension base \
|
||||
--only-schema \
|
||||
--output-dir ./exports
|
||||
|
||||
# 允许覆盖已存在文件
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
@@ -92,6 +100,7 @@ lark-cli drive +export \
|
||||
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` |
|
||||
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` / `base` / `pptx` |
|
||||
| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 |
|
||||
| `--only-schema` | 否 | 仅当 `--doc-type bitable --file-extension base` 时可用;只导出多维表格结构,不导出记录数据 |
|
||||
| `--file-name` | 否 | 覆盖默认本地文件名;如未带扩展名,会按 `--file-extension` 自动补齐 |
|
||||
| `--output-dir` | 否 | 本地输出目录,默认当前目录 |
|
||||
| `--overwrite` | 否 | 覆盖已存在文件 |
|
||||
@@ -100,6 +109,7 @@ lark-cli drive +export \
|
||||
|
||||
- `markdown` 只支持 `docx`
|
||||
- `base` 只支持 `bitable`
|
||||
- `--only-schema` 只支持 `bitable` 导出为 `.base`,用于仅导出表结构
|
||||
- `pptx` 只支持 `slides`
|
||||
- `slides` 支持导出为 `pptx` / `pdf`
|
||||
- `sheet` / `bitable` 导出为 `csv` 时必须带 `--sub-id`
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
> 这是 Drive 导入场景,不是 `lark-base` 的建表 / 写记录场景。
|
||||
> 只有导入完成并拿到新文档的 `token` / `url` 后,后续字段、记录、视图等表内操作才切换到 `lark-cli base +...`。
|
||||
|
||||
## 导入后标题确认
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 当用户**未传 `--name`** 时,文档标题默认取源文件名(去掉扩展名)。在执行导入前,先友好提示用户:「当前未指定文档标题,默认将使用"xxx"作为标题。如果文件内容中也包含相同标题,导入后可能造成视觉重复。是否需要重命名?」让用户确认后再继续。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
|
||||
核心特性:
|
||||
|
||||
- 把常用过滤条件全部**扁平化为独立 flag**(`--edited-since`、`--mine`、`--doc-types`、`--folder-tokens` 等),不再要求用户或 AI 手写嵌套 `--filter` JSON
|
||||
- 把常用过滤条件全部**扁平化为独立 flag**(`--edited-since`、`--created-by-me`、`--mine`、`--doc-types`、`--folder-tokens` 等),不再要求用户或 AI 手写嵌套 `--filter` JSON
|
||||
- 额外暴露了 4 个"我"维度:`my_edit_time`(我编辑过)、`my_comment_time`(我评论过)、`open_time`(我打开过)、`create_time`(文档创建时间)——直接对应用户自然语言里的"最近我编辑过的"、"我评论过的"等表达
|
||||
- 自动处理 `my_edit_time` / `my_comment_time` 的小时级聚合(服务端存储粒度):亚小时输入会向整点 snap,并在 stderr 打出提示
|
||||
- `--mine` 一键从当前登录用户的 open_id 填 `creator_ids`,不必再先去查 contact(注意 `creator_ids` 服务端按 **owner / 文档归属人** 语义匹配,不是“最初创建人”,详见下文「身份维度」)
|
||||
- `--created-by-me` 一键从当前登录用户的 open_id 填 `original_creator_ids`,匹配“我最初创建的”;`--mine` 仍填 `creator_ids`,匹配 owner / 文档归属人
|
||||
|
||||
> **资源发现入口统一**:`drive +search` 同样返回 `SHEET` / `Base` / `FOLDER` 等全部云空间(云盘/云存储)对象,不只是文档 / Wiki。用户说"找一个表格"、"找报表"、"最近打开的表格"时,也从这里开始;定位后再切到对应业务 skill(如 `lark-sheets`)做对象内部操作。
|
||||
|
||||
@@ -21,18 +21,19 @@
|
||||
> 错误:`lark-cli drive +search 方案`
|
||||
> `+search` 不接受位置参数;空 `--query` 或省略 `--query` 表示纯靠 filter 浏览(合法)。
|
||||
>
|
||||
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
|
||||
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
|
||||
|
||||
### 自然语言 → 命令映射速查
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|---|---|
|
||||
| 我这月创建的所有文档,按类型分类统计 | `lark-cli drive +search --query "" --mine --created-since "<YYYY-MM-DD>" --created-until "<YYYY-MM-DD>"` |
|
||||
| 我这月创建的所有文档,按类型分类统计 | `lark-cli drive +search --query "" --created-by-me --created-since "<YYYY-MM-DD>" --created-until "<YYYY-MM-DD>"` |
|
||||
| 最近半年我编辑过的文档,看看哪些最近更新过 | `lark-cli drive +search --query "" --edited-since 6m --sort edit_time` |
|
||||
| 最近一个月我编辑过的文档 | `lark-cli drive +search --query "" --edited-since 1m` |
|
||||
| 最近一个月我编辑过 且 我评论过的 | `lark-cli drive +search --query "" --edited-since 1m --commented-since 1m` |
|
||||
| 最近一周我打开过的表格 | `lark-cli drive +search --query "" --opened-since 7d --doc-types sheet` |
|
||||
| 我 owner 的所有文档(owner 语义,非"我最初创建") | `lark-cli drive +search --query "" --mine` |
|
||||
| 我最初创建、后来转给王五 owner 的文档 | `lark-cli drive +search --query "" --created-by-me --creator-ids ou_wangwu` |
|
||||
| 我 owner、30-60 天前创建的文档(粗略"上个月",按 30 天滑窗算;`--mine` 是 owner,`--created-*` 才是文档创建时间) | `lark-cli drive +search --query "" --mine --created-since 2m --created-until 1m` |
|
||||
| 我 owner、2026 年 3 月创建的文档(精确日历月;同上,owner + 创建时间窗两个维度) | `lark-cli drive +search --query "" --mine --created-since 2026-03-01 --created-until 2026-04-01` |
|
||||
| 关键词"预算",最近一周我打开过,按编辑时间降序 | `lark-cli drive +search --query 预算 --opened-since 7d --sort edit_time` |
|
||||
@@ -77,7 +78,7 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
|
||||
|
||||
对"所有文档"、"按类型分类统计"、"最近更新过"这类请求,不要只跑一次搜索后直接回答。标准流程:
|
||||
|
||||
1. 先把自然语言拆成过滤条件:所有权(`--mine` / `--creator-ids`)、时间维度(`--created-*` / `--edited-*` / `--opened-*` / `--commented-*`)、类型(`--doc-types`)、空间或文件夹范围。
|
||||
1. 先把自然语言拆成过滤条件:原始创建者(`--created-by-me` / `--original-creator-ids`)、所有权(`--mine` / `--creator-ids`)、时间维度(`--created-*` / `--edited-*` / `--opened-*` / `--commented-*`)、类型(`--doc-types`)、空间或文件夹范围。
|
||||
2. 没有真实业务关键词时保持 `--query ""`;不要把"所有文档"、"统计"、"最近更新"放进 query。
|
||||
3. 检查返回结果的 `doc_type` / `result_meta.doc_types`、创建/编辑时间和 URL/token 是否与过滤目标一致;明显不符合的结果不要计入答案。
|
||||
4. 用户要求"所有 / 全量 / 统计"时按 `has_more` 翻页并累积去重;不要只用第一页推断总量。返回体里的 `total` 不可靠,统计要以实际去重后的结果为准。
|
||||
@@ -103,14 +104,16 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
|
||||
| `--page-token <token>` | 否 | 上一次响应里的 `page_token`,用于翻页 |
|
||||
| `--format` | 否 | `json`(默认)/ `pretty` |
|
||||
|
||||
### 身份(owner 维度,API 字段名 `creator_ids`)
|
||||
### 身份维度
|
||||
|
||||
> **语义说明(重要)**:`creator_ids`(含 `--mine` / `--creator-ids`)虽然 OpenAPI 字段名是 “creator”,但服务端实际按 **owner(文档归属人 / 负责人)** 语义匹配,**不是“最初创建人”**:我创建后转交他人的文档不会命中,他人创建后转给我(我成为 owner)的会命中。用户说“我的 / 我创建的 / 我负责的”文档都路由到 `--mine`,但要清楚它返回的是“我 owner 的”。
|
||||
> **语义说明(重要)**:`creator_ids`(含 `--mine` / `--creator-ids`)虽然字段名是 “creator”,但服务端实际按 **owner(文档归属人 / 负责人)** 语义匹配,**不是“最初创建人”**。真正的原始创建者使用 `original_creator_ids`(CLI 为 `--created-by-me` / `--original-creator-ids`)。
|
||||
|
||||
| 参数 | 映射 | 说明 |
|
||||
|---|---|---|
|
||||
| `--mine` | `creator_ids = [当前用户 open_id]` | bool。一键“我 owner 的”(**不是**“我最初创建的”);从当前登录用户身份(`runtime.UserOpenId()`)解析 open_id,取不到直接报错(提示运行 `lark-cli auth login`) |
|
||||
| `--creator-ids ou_x,ou_y` | `creator_ids = [...]` | 显式 open_id 列表,逗号分隔,按 **owner** 匹配;**与 `--mine` 互斥** |
|
||||
| `--created-by-me` | `original_creator_ids = [当前用户 open_id]` | bool。一键“我最初创建的”;从当前登录用户身份解析 open_id,取不到直接报错 |
|
||||
| `--original-creator-ids ou_x,ou_y` | `original_creator_ids = [...]` | 显式 open_id 列表,逗号分隔,按**原始创建者**匹配;**与 `--created-by-me` 互斥** |
|
||||
|
||||
### 时间维度(每个维度一对 since/until)
|
||||
|
||||
@@ -187,7 +190,7 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
|
||||
|
||||
## 决策规则
|
||||
|
||||
- **身份快捷方式**:用户说“我的 / 我创建的 / 我负责的”文档,直接 `--mine` 即可,不需要先查 contact 拿 open_id。注意 `--mine` 是 **owner** 语义(我归属/负责的),不是“我最初创建的”——转交出去的不算、转交给我的算。
|
||||
- **身份快捷方式**:用户说“我创建的 / 我新建的 / 我最初创建的”文档,用 `--created-by-me`;用户说“我的 / 我负责的 / 我 owner 的”文档,用 `--mine`。`--mine` 是 owner 语义:转交出去的不算、转交给我的算。
|
||||
- **时间维度选择**:
|
||||
- "我编辑的"、"我修改的" → `--edited-since` / `--edited-until`
|
||||
- "我评论的"、"我回复过的" → `--commented-since` / `--commented-until`
|
||||
@@ -197,10 +200,10 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
|
||||
- "某个文件夹下" → `--folder-tokens`(doc-only)
|
||||
- "某个 wiki 空间下" → `--space-ids`(wiki-only)
|
||||
- 两者不能同时使用,混用会报错
|
||||
- **身份 flag 互斥**:`--mine` 和 `--creator-ids` 不要同时传,会直接报错。“我和张三的”(owner)用 `--creator-ids ou_me,ou_zhangsan`(需要先拿到自己 open_id,但这种场景少见)。
|
||||
- **身份 flag 互斥**:`--mine` 和 `--creator-ids` 不要同时传;`--created-by-me` 和 `--original-creator-ids` 不要同时传。owner 维度与原始创建者维度可以组合,例如“我创建后转给王五 owner”用 `--created-by-me --creator-ids ou_wangwu`。
|
||||
- **实体补全**:
|
||||
- 用户说"某个群里",先用 `lark-im` 查 `chat_id`
|
||||
- 用户说“某人的 / 某人分享的”(非自己;`--creator-ids` 按 owner 匹配),先用 `lark-contact` 查 open_id,再填 `--creator-ids` / `--sharer-ids`
|
||||
- 用户说“某人负责/owner 的 / 某人创建的 / 某人分享的”(非自己),先用 `lark-contact` 查 open_id,再按语义填 `--creator-ids` / `--original-creator-ids` / `--sharer-ids`
|
||||
- **查询语义下推**:`--query` 支持的服务端高级语法(`intitle:`、`""`、`OR`、`-`)优先使用,不要先模糊搜再在客户端二次过滤。
|
||||
- **query 填写边界**:只有标题片段、业务名词、项目名、会议名、文件内容关键词才应进入 `--query`。仅描述动作、时间范围、所有权、统计方式的词不算关键词,保持 `--query ""` 并依赖 filters。
|
||||
- **证据核验**:列表/统计类答案必须来自搜索结果中的实际 URL/token 和类型/时间字段;内容问答必须能指出使用了哪些非污染候选。没有可验证候选时先扩大 query 或翻页,不要直接编总结。
|
||||
@@ -222,7 +225,7 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
|
||||
|
||||
| code | 含义 | 处理 |
|
||||
|---|---|---|
|
||||
| `99992351` | `--creator-ids` / `--sharer-ids` 里有 open_id 超出**应用的通讯录可见范围**,服务端拒绝识别 | 让管理员在开发者后台把这些用户加进应用的"通讯录可见性"授权里;或把超出范围的 open_id 从参数里去掉。这和 `search:docs:read` scope 不是一回事 —— 是"应用能看见哪些人"而不是"应用能调用哪个接口" |
|
||||
| `99992351` | `--creator-ids` / `--original-creator-ids` / `--sharer-ids` 里有 open_id 超出**应用的通讯录可见范围**,服务端拒绝识别 | 让管理员在开发者后台把这些用户加进应用的"通讯录可见性"授权里;或把超出范围的 open_id 从参数里去掉。这和 `search:docs:read` scope 不是一回事 —— 是"应用能看见哪些人"而不是"应用能调用哪个接口" |
|
||||
|
||||
## 时间范围自动裁剪(`--opened-*` 专有)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user