Compare commits

...

21 Commits

Author SHA1 Message Date
zhengzhijie
0f238c25e0 docs(sheets): sync lark-sheets skill from sheet-skill-spec (add +recover)
Mirror generated/ from sheet-skill-spec: adds the +recover shortcut — skill
reference lark-sheets-recover.md + flag-defs/flag-schemas entry (方案B,
full-document rollback). Also brings this branch's lark-sheets skill in line
with the latest spec develop (workbook / write-cells reference + flag updates
larksuite-cli had not yet synced).
2026-06-12 17:12:57 +08:00
zhengzhijie
c95ab1c28e feat(sheets): add +recover shortcut (方案B, full-document revision rollback)
Splits 方案B (recover) into its own MR, shipping ahead of 方案A (undo).
+recover rolls the whole spreadsheet back to a past revision via the facade
recover_to_revision write tool; only the recover shortcut and its flag live
here. undo (方案A) and the read/write transaction_id split stay in PR #1321.
2026-06-11 21:55:06 +08:00
zhengzhijiej-tech
bed30c4ecb Merge pull request #1351 from larksuite/fix/chart-dim-insert-example
docs(sheets): chart / filter / workbook reference corrections
2026-06-09 16:47:31 +08:00
zhengzhijie
a7be567066 docs(sheets): label +sheet-create --index as 0-based
The base flag description for +sheet-create's --index omitted the
coordinate base, while its siblings +sheet-move ("Target position
(0-based)") and +sheet-copy already state 0-based. Align the description
so the index base is unambiguous. Synced from the spec source
(flag-defs.json + workbook reference).
2026-06-09 16:25:02 +08:00
zhengzhijie
e96acad2c5 docs(sheets): chart coordinate base / quoting + filter condition enums
Sync three reference-doc corrections from the spec source:

1. chart: label position.row as 0-based (first row = row:0), distinct
   from the 1-based row numbers used by A1 ranges and +dim-insert
   --position, removing the row-base ambiguity.

2. chart: convert the three runnable examples whose JSON contains a
   quoted sheet prefix ('Sheet1'!A1) from inline single-quoted
   --properties '{...}' to a stdin heredoc (--properties - <<'JSON').
   Inside an inline single-quoted string bash strips the inner quotes
   around the sheet name (and splits names with spaces into words),
   corrupting the JSON; a quoted heredoc delimiter performs no shell
   substitution and preserves it. Adds a short note on the pitfall.

3. filter / filter-view: add the full conditions[].type x compare_type
   enum table (text / number / multiValue / color and their respective
   compare_type values and values shape), and call out the
   equals/notEquals (with s) vs equal/notEqual (no s) gotcha. The docs
   previously only showed two values via examples.
2026-06-09 16:25:02 +08:00
zhengzhijie
7ac8a7d30e docs(sheets): fix invalid +dim-insert example in chart reference
The chart reference's placement example used non-existent flags
--dimension/--start/--end for +dim-insert. The real signature is
--position (required) + --count (required); copying the example
fails Validate with "--position is required". Replace it with
+dim-insert --position V --count 6 (insert 6 columns before V,
i.e. after U), aligning with the sheet-structure reference.
2026-06-09 15:34:05 +08:00
xiongyuanwen-byted
31523b7f50 docs(sheets): align +csv-put help with formula support
Sync the formula-support wording from sheet-skill-spec (flag-defs, skill
references) and update the hand-authored cobra Description and comment for
+csv-put. +csv-put evaluates a leading-= cell as a formula via
set_range_from_csv; descriptions only, no behavior change.
2026-06-08 20:38:10 +08:00
zhengzhijiej-tech
02a37029c2 Merge pull request #1296 from larksuite/feat/sheet-eval-guidance-fixes
docs(sheets): strengthen lark-sheets references for common editing pitfalls
2026-06-08 19:13:29 +08:00
zhengzhijie
556d7e3a77 docs(sheets): align write-cells reference with the generated output
Bring the hand-applied write-cells example in line with the spec-generated
reference so the CLI mirror is byte-identical to the canonical source.
2026-06-08 19:07:44 +08:00
Chenweifeng-bd
f18a082a4f docs: add lark sheets financial modeling guidance 2026-06-08 17:05:11 +08:00
zhengzhijie
b8c5176483 docs(sheets): reword guidance to avoid eval-specific phrasing
Replace scoring-framework wording in the examples with plain functional
consequences (e.g. "not delivered", "goes stale when the source changes",
"breaks the original visual format"), so the references stay agent-facing.
2026-06-08 15:44:35 +08:00
zhengzhijie
82937a0a37 docs(sheets): keep original column widths; align chart axis with requested metric
- range-operations: only widen new / overflowing columns; never recompute or
  shrink the widths of existing columns (any blanket resize, even by 1px,
  breaks the original visual format)
- chart: when the user asks for a share / percentage, the value axis should be
  a percentage (pie, or stack.percentage on bar/column) rather than raw counts
2026-06-08 14:38:00 +08:00
xiongyuanwen-byted
1cafb94a62 refactor(sheets): reuse the drive export core in +workbook-export
Replace +workbook-export's parallel export-task implementation with the shared drive ExportParams/RunExport core (pinned to type=sheet). Drops ~90 lines of duplicated poll/download code; +workbook-export now inherits drive's ctx cancellation, resume-on-timeout, filename sanitize/overwrite, and the full set of export status labels. The output contract aligns with drive's (adds ready/downloaded/doc_type; saved_path preserved). Also normalize an empty drive --output-dir to "." so drive +export behavior is unchanged, and fix the sheets export e2e to call +workbook-export instead of a nonexistent +export.
2026-06-08 12:58:11 +08:00
xiongyuanwen-byted
0b33daa136 feat(sheets): add +workbook-import wrapping the drive import core
Import a local xlsx/xls/csv as a new spreadsheet by delegating to the shared drive import flow with the target type pinned to sheet. Refactor drive +import to expose ImportParams / ValidateImport / PlanImportDryRun / RunImport (behavior unchanged, existing drive tests still cover it); sheets reuses them. Regenerate flag_defs_gen.go and sync the spec mirror.
2026-06-08 11:00:46 +08:00
xiongyuanwen-byted
5a61b97ac3 docs(sheets): sync SKILL.md (drop "Feishu sheets only" caveat)
Mirror the upstream sheet-skill-spec change removing the "applies to Feishu sheets only" tail from the 14 sheet reference descriptions.
2026-06-07 22:45:53 +08:00
xiongyuanwen-byted
e01f2dfdd5 docs(sheets): sync SKILL.md (drop "not for local Excel" caveat)
Mirror the upstream sheet-skill-spec change removing the "not applicable to local Excel files" tail from the sheets skill and reference descriptions.
2026-06-07 22:39:58 +08:00
xiongyuanwen-byted
45f807459e docs(sheets): surface typed-write path at the write-decision point
Quick-ref table (SKILL.md, the first decision point) had no +table-put and
gated typed writes on "DataFrame", so a model holding a Counter/list/dict
would fall back to +csv-put and silently lose number/date fidelity.

- split csv-put row to plain-text values (no numeric/date semantics)
- add +table-put row for typed writes into an existing sheet
- add +workbook-create --sheets row for create + typed write in one shot
- add judgment note: number/amount/date/percent/count -> +table-put
  (or +workbook-create --sheets when the workbook does not exist yet);
  plain text -> +csv-put
- reframe write-cells scenario row to lead with numeric semantics
- point new-table writes at +workbook-create --sheets (one shot) instead
  of the create-empty-then-table-put two-step

Synced from sheet-skill-spec canonical (generate:cli + sync:cli).
2026-06-07 00:30:13 +08:00
xiongyuanwen-byted
8906e87fb1 feat(sheets): implement table-put/table-get and sync skill specs
- Add lark_sheet_table_io.go with +table-put / +table-get and tests
- Refactor read-data; extend workbook; register new shortcuts
- Sync generated flag defs/schemas (go:embed) from sheet-skill-spec
- Sync skill references (write-cells numeric-column guidance, plus
  read-data / workbook / chart updates)
2026-06-05 20:03:33 +08:00
zhengzhijie
d5a53d921d docs(sheets): strengthen lark-sheets references for common editing pitfalls
Add targeted guidance to six lark-sheets references to reduce frequent
mistakes when editing spreadsheets through the CLI:

- write-cells: sanity-check units / dimension conversion / quantity factors
  before formula writes (formulas can run clean yet be off by a factor);
  keep derived output off original data columns to avoid clobbering source
- core-operations: prefer live formulas for derived values even when "live
  update" is not explicitly requested; scope rewrite/transform precisely so
  rows/columns that should stay unchanged are kept 1:1; treat header-stated
  format rules as checklist items; confirm the artifact file actually exists
  before finishing; write back bare values from local scripts
- visual-standards: apply border/header formatting on explicit request and
  identify the real header row; keep font size consistent with the source
- range-operations: keep total column width within A4 for printing
- read-data: dedup/compare long numbers via raw values, not csv formatted
  display (scientific notation collapses distinct numbers and causes false
  duplicates)
- chart: format date/number axes via source-cell number_format; place charts
  outside the data area so they do not cover existing data
2026-06-05 19:20:25 +08:00
zhengzhijiej-tech
0ff7f0407e Merge pull request #1264 from zhengzhijiej-tech/feat/sheet-gridline
feat(sheets): add gridline show/hide shortcuts
2026-06-04 19:12:41 +08:00
zhengzhijie
6e067f2180 feat(sheets): add +sheet-show-gridline / +sheet-hide-gridline shortcuts 2026-06-04 17:00:07 +08:00
33 changed files with 4681 additions and 959 deletions

View File

@@ -39,230 +39,296 @@ var DriveExport = common.Shortcut{
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveExportSpec(driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
})
return ValidateExport(exportParamsFromFlags(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
}
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
},
}
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
}
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
// ExportParams holds the user-facing inputs for an export flow, decoupled from
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
// the drive export implementation. An empty OutputDir means "create the export
// task and poll, but do not download" — callers that only need the ready file
// token / status get it back without writing a local file.
type ExportParams struct {
Token string
DocType string
FileExtension string
SubID string
OutputDir string
FileName string
Overwrite bool
}
func (p ExportParams) spec() driveExportSpec {
return driveExportSpec{
Token: p.Token,
DocType: p.DocType,
FileExtension: p.FileExtension,
SubID: p.SubID,
}
}
// exportParamsFromFlags reads the standard drive +export flag set.
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
// drive +export always downloads; an empty --output-dir historically means
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
// it here to keep behavior identical and stay off the export-only ("" => skip
// download) path that only sheets +workbook-export uses.
outputDir := runtime.Str("output-dir")
if outputDir == "" {
outputDir = "."
}
return ExportParams{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OutputDir: outputDir,
FileName: strings.TrimSpace(runtime.Str("file-name")),
Overwrite: runtime.Bool("overwrite"),
}
}
// ValidateExport runs the CLI-level export constraint checks.
func ValidateExport(p ExportParams) error {
return validateDriveExportSpec(p.spec())
}
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
spec := p.spec()
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
Desc("2-step orchestration: fetch docx markdown -> write local file").
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
}
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
}
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
dr := common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
// RunExport drives create export task -> bounded poll -> optional download. It
// is the shared core behind both drive +export and sheets +workbook-export. An
// empty p.OutputDir skips the download step and returns the ready file token.
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
spec := p.spec()
outputDir := p.OutputDir
preferredFileName := strings.TrimSpace(p.FileName)
overwrite := p.Overwrite
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.CallAPITyped(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
if err != nil {
return err
}
outputDir := runtime.Str("output-dir")
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
overwrite := runtime.Bool("overwrite")
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.CallAPITyped(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
}
content, ok := doc["content"].(string)
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
}
fileName := preferredFileName
if fileName == "" {
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
return err
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
}
fileName = title
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
if err != nil {
return err
}
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
runtime.Out(map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(content),
}, nil)
return nil
}
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
var lastStatus driveExportStatus
var lastPollErr error
hasObservedStatus := false
// Keep the command responsive by polling for a bounded window. If the task
// is still running after that, return a resume command instead of blocking.
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(driveExportPollInterval):
}
content, ok := doc["content"].(string)
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
}
if err := ctx.Err(); err != nil {
return err
}
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
if err != nil {
// Treat polling failures as transient so short-lived backend hiccups
// do not immediately fail an otherwise healthy export task.
lastPollErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
continue
}
lastStatus = status
hasObservedStatus = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
// Export-only mode: caller wants the ready file token / metadata but
// no local download (e.g. sheets +workbook-export without an output
// path). Skip the download and return the status envelope.
if strings.TrimSpace(outputDir) == "" {
runtime.Out(map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_token": status.FileToken,
"file_name": status.FileName,
"file_size": status.FileSize,
"ready": true,
"downloaded": false,
}, nil)
return nil
}
fileName := preferredFileName
if fileName == "" {
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
}
fileName = title
fileName = status.FileName
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
if err != nil {
return err
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
hint := fmt.Sprintf(
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
ticket,
status.FileToken,
recoveryCommand,
)
return appendDriveExportRecoveryHint(err, hint)
}
runtime.Out(map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(content),
}, nil)
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
runtime.Out(out, nil)
return nil
}
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
var lastStatus driveExportStatus
var lastPollErr error
hasObservedStatus := false
// Keep the command responsive by polling for a bounded window. If the task
// is still running after that, return a resume command instead of blocking.
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(driveExportPollInterval):
}
if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
if err := ctx.Err(); err != nil {
return err
}
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
if err != nil {
// Treat polling failures as transient so short-lived backend hiccups
// do not immediately fail an otherwise healthy export task.
lastPollErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
continue
}
lastStatus = status
hasObservedStatus = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
fileName := preferredFileName
if fileName == "" {
fileName = status.FileName
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
if err != nil {
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
hint := fmt.Sprintf(
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
ticket,
status.FileToken,
recoveryCommand,
)
return appendDriveExportRecoveryHint(err, hint)
}
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
runtime.Out(out, nil)
return nil
}
if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
}
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
}
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
if !hasObservedStatus && lastPollErr != nil {
hint := fmt.Sprintf(
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
ticket,
nextCommand,
)
return appendDriveExportRecoveryHint(lastPollErr, hint)
}
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
}
failed := false
var jobStatus interface{}
jobStatusLabel := "unknown"
if hasObservedStatus {
failed = lastStatus.Failed()
jobStatus = lastStatus.JobStatus
jobStatusLabel = lastStatus.StatusLabel()
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"ready": false,
"failed": failed,
"job_status": jobStatus,
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
},
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
if !hasObservedStatus && lastPollErr != nil {
hint := fmt.Sprintf(
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
ticket,
nextCommand,
)
return appendDriveExportRecoveryHint(lastPollErr, hint)
}
failed := false
var jobStatus interface{}
jobStatusLabel := "unknown"
if hasObservedStatus {
failed = lastStatus.Failed()
jobStatus = lastStatus.JobStatus
jobStatusLabel = lastStatus.StatusLabel()
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"ready": false,
"failed": failed,
"job_status": jobStatus,
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
}

View File

@@ -488,6 +488,72 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
}
}
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
// explicit empty --output-dir must still download to the current directory
// (normalized to "."), not trigger the export-only no-download path that the
// shared RunExport core uses for sheets +workbook-export.
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_e",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0, "file_token": "box_e", "file_name": "report",
"file_extension": "pdf", "type": "docx", "file_size": 3,
},
}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--output-dir", "",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Empty --output-dir must still write to cwd, not skip the download.
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
if err != nil {
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
}
if string(data) != "pdf" {
t.Fatalf("downloaded content = %q", string(data))
}
if strings.Contains(stdout.String(), `"downloaded": false`) {
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
}
}
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{

View File

@@ -34,128 +34,160 @@ var DriveImport = common.Shortcut{
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveImportSpec(driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
})
return ValidateImport(importParamsFromFlags(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if valErr := validateDriveImportSpec(spec); valErr != nil {
return common.NewDryRunAPI().Set("error", valErr.Error())
}
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
appendDriveImportUploadDryRun(dry, spec, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
Body(spec.CreateTaskBody("<file_token>"))
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
Desc("[3] Poll import task result").
Set("ticket", "<ticket>")
if runtime.IsBot() {
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
}
return dry
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err
}
// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
if uploadErr != nil {
return uploadErr
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
// Step 2: Create import task
ticket, err := createDriveImportTask(runtime, spec, fileToken)
if err != nil {
return err
}
// Step 3: Poll task
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
status, ready, err := pollDriveImportTask(runtime, ticket)
if err != nil {
return err
}
// Some intermediate responses omit the final type, so fall back to the
// requested type to keep the output shape stable.
resultType := status.DocType
if resultType == "" {
resultType = spec.DocType
}
out := map[string]interface{}{
"ticket": ticket,
"type": resultType,
"ready": ready,
"job_status": status.JobStatus,
"job_status_label": status.StatusLabel(),
}
if status.Token != "" {
out["token"] = status.Token
}
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
out["url"] = statusURL
} else if status.Token != "" {
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
out["url"] = u
}
}
if status.JobErrorMsg != "" {
out["job_error_msg"] = status.JobErrorMsg
}
if status.Extra != nil {
out["extra"] = status.Extra
}
if !ready {
nextCommand := driveImportTaskResultCommand(ticket)
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
if ready {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
out["permission_grant"] = grant
}
}
runtime.Out(out, nil)
return nil
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
},
}
// ImportParams holds the user-facing inputs for an import flow, decoupled from
// cobra flags so other command groups (e.g. sheets +workbook-import) can reuse
// the drive import implementation without taking a dependency on a --type flag.
type ImportParams struct {
File string
DocType string
FolderToken string
Name string
TargetToken string
}
func (p ImportParams) spec() driveImportSpec {
return driveImportSpec{
FilePath: p.File,
DocType: strings.ToLower(p.DocType),
FolderToken: p.FolderToken,
Name: p.Name,
TargetToken: p.TargetToken,
}
}
// importParamsFromFlags reads the standard drive +import flag set.
func importParamsFromFlags(runtime *common.RuntimeContext) ImportParams {
return ImportParams{
File: runtime.Str("file"),
DocType: runtime.Str("type"),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
}
// ValidateImport runs the CLI-level compatibility checks for an import.
func ValidateImport(p ImportParams) error {
return validateDriveImportSpec(p.spec())
}
// PlanImportDryRun builds the dry-run plan (upload -> create task -> poll) for
// an import without performing any network or file I/O beyond a local stat.
func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.DryRunAPI {
spec := p.spec()
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if valErr := validateDriveImportSpec(spec); valErr != nil {
return common.NewDryRunAPI().Set("error", valErr.Error())
}
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
appendDriveImportUploadDryRun(dry, spec, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
Body(spec.CreateTaskBody("<file_token>"))
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
Desc("[3] Poll import task result").
Set("ticket", "<ticket>")
if runtime.IsBot() {
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
}
return dry
}
// RunImport executes the full import flow: upload media -> create import task ->
// bounded poll, then writes the result envelope to the runtime output. It is
// the shared core behind both drive +import and sheets +workbook-import.
func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportParams) error {
spec := p.spec()
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err
}
// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
if uploadErr != nil {
return uploadErr
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
// Step 2: Create import task
ticket, err := createDriveImportTask(runtime, spec, fileToken)
if err != nil {
return err
}
// Step 3: Poll task
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
status, ready, err := pollDriveImportTask(runtime, ticket)
if err != nil {
return err
}
// Some intermediate responses omit the final type, so fall back to the
// requested type to keep the output shape stable.
resultType := status.DocType
if resultType == "" {
resultType = spec.DocType
}
out := map[string]interface{}{
"ticket": ticket,
"type": resultType,
"ready": ready,
"job_status": status.JobStatus,
"job_status_label": status.StatusLabel(),
}
if status.Token != "" {
out["token"] = status.Token
}
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
out["url"] = statusURL
} else if status.Token != "" {
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
out["url"] = u
}
}
if status.JobErrorMsg != "" {
out["job_error_msg"] = status.JobErrorMsg
}
if status.Extra != nil {
out["extra"] = status.Extra
}
if !ready {
nextCommand := driveImportTaskResultCommand(ticket)
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
if ready {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
out["permission_grant"] = grant
}
}
runtime.Out(out, nil)
return nil
}
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
// Keep dry-run and execution aligned on path normalization, file existence,
// and format-specific size limits before planning the upload path.

View File

@@ -177,6 +177,18 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
},
{
shortcut: "+sheet-show-gridline",
sc: SheetShowGridline,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+sheet-hide-gridline",
sc: SheetHideGridline,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+dropdown-set",
sc: DropdownSet,

View File

@@ -152,6 +152,12 @@ var batchOpDispatch = map[string]batchOpMapping{
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
}},
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
"+sheet-show-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "show_gridline")
}},
"+sheet-hide-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "hide_gridline")
}},
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},

View File

@@ -54,7 +54,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Insert position; appended to the end when omitted",
"desc": "Insert position (0-based); appended to the end when omitted",
"default": "-1"
},
{
@@ -413,6 +413,86 @@
}
]
},
"+sheet-hide-gridline": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+sheet-show-gridline": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+workbook-create": {
"risk": "write",
"flags": [
@@ -431,22 +511,33 @@
"desc": "Target folder token; placed at the drive root when omitted"
},
{
"name": "headers",
"name": "values",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`",
"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": [
"file",
"stdin"
]
},
{
"name": "values",
"name": "sheets",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`",
"desc": "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool. Mutually exclusive with --values. 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"
@@ -513,6 +604,32 @@
}
]
},
"+workbook-import": {
"risk": "write",
"flags": [
{
"name": "file",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Local file path (.xlsx / .xls / .csv)"
},
{
"name": "folder-token",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Target folder token; imported to the cloud drive root when omitted"
},
{
"name": "name",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Imported spreadsheet name; defaults to the local file name without its extension"
}
]
},
"+sheet-info": {
"risk": "read",
"flags": [
@@ -1212,19 +1329,65 @@
"desc": "Skip hidden rows and columns; default `false`"
},
{
"name": "rows-json",
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request path and parameters without executing"
}
]
},
"+table-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Read only this sheet (by id); omit to read all sheets"
},
{
"name": "sheet-name",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Read only this sheet (by name); omit to read all sheets"
},
{
"name": "range",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "A1 range to read; omit to read each sheet current region"
},
{
"name": "no-header",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false",
"default": "false"
"desc": "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request path and parameters without executing"
"desc": ""
}
]
},
@@ -1849,7 +2012,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)",
"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": [
"file",
"stdin"
@@ -1880,6 +2043,43 @@
}
]
},
"+table-put": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token to write into (XOR with `--url`)"
},
{
"name": "sheets",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Typed table payload as JSON: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool",
"input": [
"file",
"stdin"
]
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+cells-clear": {
"risk": "high-risk-write",
"flags": [
@@ -4538,5 +4738,38 @@
"desc": ""
}
]
},
"+recover": {
"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": "to-revision",
"kind": "own",
"type": "int",
"required": "required",
"desc": "Restore the whole spreadsheet to this revision (a revision number returned by a prior write)"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
}
}

View File

@@ -1730,11 +1730,12 @@
},
"aggregateType": {
"type": "string",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
"enum": [
"sum",
"average",
"count",
"counta",
"min",
"max",
"median"
@@ -1787,11 +1788,7 @@
"data"
]
}
},
"required": [
"position",
"size"
]
}
}
},
"+chart-update": {
@@ -2769,11 +2766,12 @@
},
"aggregateType": {
"type": "string",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
"enum": [
"sum",
"average",
"count",
"counta",
"min",
"max",
"median"
@@ -2826,11 +2824,7 @@
"data"
]
}
},
"required": [
"position",
"size"
]
}
}
},
"+cond-format-create": {
@@ -6249,6 +6243,477 @@
}
}
}
},
"+table-put": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。可由 pandas DataFrame 经薄 helper 生成NaN→null、Timestamp→ISO、numpy 标量→原生)。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"rows"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列定义,顺序与 rows 中每行的取值一一对应。",
"items": {
"type": "object",
"required": [
"name",
"type"
],
"properties": {
"name": {
"type": "string",
"description": "列名(写入表头行的文本)。"
},
"type": {
"type": "string",
"enum": [
"string",
"number",
"date",
"bool"
],
"description": "列的声明类型,显式声明、不由 CLI 猜测(避免邮编 / 订单号 / 手机号等「像数字的文本」被误判为数字。string 列由 +table-put 自动套文本格式number_format `@`数字样字符串含前导零如「00123」读写两侧都保真——+table-get 读回时仍判为 string、不会塌缩成数字。date 列取 ISO yyyy-mm-dd 字符串CLI 转成 Excel 序列号 + 日期 number_format真日期可排序 / 透视 / 筛选)。"
},
"format": {
"type": "string",
"description": "可选的单元格 number_format如 \"yyyy-mm-dd\" / \"0.00%\" / \"#,##0.00\"。percent 列的数值尺度由调用方负责0.0469 配 \"0.00%\"helper 不自动乘 100。"
}
}
}
},
"rows": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 columns 数。元素按对应列的类型取值null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值,按所在列的 type 取值string→文本 / number→数值 / date→ISO yyyy-mm-dd 文本 / bool→布尔null 表示空单元格。具体类型由该列在 columns 里声明的 type 决定,故此处仅约束为标量或 null。"
}
}
}
}
}
}
},
"+workbook-create": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。可由 pandas DataFrame 经薄 helper 生成NaN→null、Timestamp→ISO、numpy 标量→原生)。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"rows"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列定义,顺序与 rows 中每行的取值一一对应。",
"items": {
"type": "object",
"required": [
"name",
"type"
],
"properties": {
"name": {
"type": "string",
"description": "列名(写入表头行的文本)。"
},
"type": {
"type": "string",
"enum": [
"string",
"number",
"date",
"bool"
],
"description": "列的声明类型,显式声明、不由 CLI 猜测(避免邮编 / 订单号 / 手机号等「像数字的文本」被误判为数字。string 列由 +table-put 自动套文本格式number_format `@`数字样字符串含前导零如「00123」读写两侧都保真——+table-get 读回时仍判为 string、不会塌缩成数字。date 列取 ISO yyyy-mm-dd 字符串CLI 转成 Excel 序列号 + 日期 number_format真日期可排序 / 透视 / 筛选)。"
},
"format": {
"type": "string",
"description": "可选的单元格 number_format如 \"yyyy-mm-dd\" / \"0.00%\" / \"#,##0.00\"。percent 列的数值尺度由调用方负责0.0469 配 \"0.00%\"helper 不自动乘 100。"
}
}
}
},
"rows": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 columns 数。元素按对应列的类型取值null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值,按所在列的 type 取值string→文本 / number→数值 / date→ISO yyyy-mm-dd 文本 / bool→布尔null 表示空单元格。具体类型由该列在 columns 里声明的 type 决定,故此处仅约束为标量或 null。"
}
}
}
}
}
},
"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:Ctype 为 pixel/standardpixel 需要 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:3type 为 pixel/standard/autopixel 需要 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"
}
}
}
}

View File

@@ -308,7 +308,6 @@ var flagDefs = map[string]commandDef{
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
},
},
@@ -735,6 +734,15 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+recover": {
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: "to-revision", Kind: "own", Type: "int", Required: "required", Desc: "Restore the whole spreadsheet to this revision (a revision number returned by a prior write)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+rows-resize": {
Risk: "write",
Flags: []flagDef{
@@ -793,6 +801,16 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-hide-gridline": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-info": {
Risk: "read",
Flags: []flagDef{
@@ -839,6 +857,16 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-show-gridline": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-unhide": {
Risk: "write",
Flags: []flagDef{
@@ -895,6 +923,28 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+table-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet current region"},
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+table-put": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
{Name: "sheets", Kind: "own", Type: "string", Required: "required", Desc: "Typed table payload as JSON: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool", Input: []string{"file", "stdin"}},
{Name: "header-style", Kind: "own", Type: "bool", Required: "optional", Desc: "Bold the header row written from column names (default true)", Default: "true"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-create": {
Risk: "write",
Flags: []flagDef{
@@ -902,6 +952,8 @@ var flagDefs = map[string]commandDef{
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool. Mutually exclusive with --headers/--values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
{Name: "header-style", Kind: "own", Type: "bool", Required: "optional", Desc: "Bold the typed header row (only with --sheets; default true)", Default: "true"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -916,6 +968,14 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-import": {
Risk: "write",
Flags: []flagDef{
{Name: "file", Kind: "own", Type: "string", Required: "required", Desc: "Local file path (.xlsx / .xls / .csv)"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; imported to the cloud drive root when omitted"},
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Imported spreadsheet name; defaults to the local file name without its extension"},
},
},
"+workbook-info": {
Risk: "read",
Flags: []flagDef{

View File

@@ -32,4 +32,6 @@ var commandsWithSchema = map[string]struct{}{
"+range-sort": {},
"+sparkline-create": {},
"+sparkline-update": {},
"+table-put": {},
"+workbook-create": {},
}

View File

@@ -5,8 +5,6 @@ package sheets
import (
"context"
"encoding/csv"
"regexp"
"strconv"
"strings"
@@ -164,12 +162,7 @@ var CsvGet = common.Shortcut{
if err != nil {
return err
}
switch {
case runtime.Bool("rows-json"):
// --rows-json reshapes the CSV response into structured rows
// ({row_number, values:{col→cell}}); see assembleRowsJSON.
out = assembleRowsJSON(out, strings.TrimSpace(runtime.Str("range")))
case !runtime.Bool("include-row-prefix"):
if !runtime.Bool("include-row-prefix") {
out = stripRowPrefixFromCsvOutput(out)
}
runtime.Out(out, nil)
@@ -219,141 +212,6 @@ func stripRowPrefixFromCsvOutput(out interface{}) interface{} {
return m
}
// rowPrefixRe matches the leading "[row=N] " (or "[row=N],") annotation that
// the tool prepends to the first physical line of each logical CSV record.
var rowPrefixRe = regexp.MustCompile(`^\[row=(\d+)\][ ,]?`)
// assembleRowsJSON reshapes the tool's annotated_csv string into structured
// rows so callers never have to regex-parse "[row=N]" or RFC-4180 CSV by hand:
//
// {
// "range": "A1:K3380",
// "current_region": "...", // passthrough, if the tool returned it
// "rows": [{"row_number":1,"values":{"A":"姓名", ..., "K":"时间差_分钟"}},
// {"row_number":2,"values":{"A":"张三", ..., "K":"8.5"}}, ...]
// }
//
// Every logical row is emitted, including the first — no row is assumed to be a
// header, since sheet data is not always tabular. Each cell is keyed by its
// column letter (from the tool's col_indices when present, else derived from the
// requested range's start column). On any parsing trouble it returns the
// original output unchanged.
func assembleRowsJSON(out interface{}, requestedRange string) interface{} {
m, ok := out.(map[string]interface{})
if !ok {
return out
}
csvStr, ok := m["annotated_csv"].(string)
if !ok {
return out
}
// Group physical lines into logical records by [row=N] boundaries; lines
// without a prefix are embedded-newline continuations of the current record.
type logicalRow struct {
num int
text string
}
var groups []logicalRow
for _, line := range strings.Split(csvStr, "\n") {
if mm := rowPrefixRe.FindStringSubmatch(line); mm != nil {
n, _ := strconv.Atoi(mm[1])
groups = append(groups, logicalRow{num: n, text: line[len(mm[0]):]})
} else if len(groups) > 0 {
groups[len(groups)-1].text += "\n" + line
}
}
if len(groups) == 0 {
return out
}
// Parse every logical row; widest row sets the column count. No row is
// singled out as a header — that would assume the data is tabular, which it
// often is not. The model reads row 1 like any other row and decides for
// itself whether it is a header.
parsed := make([][]string, len(groups))
maxCols := 0
for i, g := range groups {
parsed[i] = parseCSVRecord(g.text)
if len(parsed[i]) > maxCols {
maxCols = len(parsed[i])
}
}
if maxCols == 0 {
return out
}
// Column letters key each cell. Prefer the tool's col_indices (authoritative,
// length == col_count); otherwise derive from the requested range's start col.
letters := coerceStringSlice(m["col_indices"])
if len(letters) < maxCols {
start := csvStartColIndex(requestedRange)
letters = make([]string, maxCols)
for j := 0; j < maxCols; j++ {
letters[j] = csvColLetter(start + j)
}
}
rows := make([]map[string]interface{}, 0, len(groups))
for i := range groups {
fields := parsed[i]
values := make(map[string]interface{}, len(letters))
for j := range letters {
v := ""
if j < len(fields) {
v = fields[j]
}
values[letters[j]] = v
}
rows = append(rows, map[string]interface{}{
"row_number": groups[i].num,
"values": values,
})
}
result := map[string]interface{}{}
for k, v := range m {
result[k] = v
}
result["range"] = requestedRange
result["rows"] = rows
// Surface the backend's "数据没读全" signal structurally instead of leaving it
// buried in warning_message prose. The tool flags it when current_region (the
// true data extent) reaches past actual_range (what was actually read) — the
// single most important anti-under-read hint. Mirror that same comparison
// (regionEndRow > actualEndRow) from the already-passthrough A1 ranges so the
// model gets the real data range as a first-class field, never having to
// parse it out of prose.
if cr, _ := m["current_region"].(string); cr != "" {
ar, _ := m["actual_range"].(string)
regionEnd := a1EndRow(cr)
readEnd := a1EndRow(ar)
if regionEnd > 0 && readEnd > 0 && regionEnd > readEnd {
result["data_not_fully_read"] = map[string]interface{}{
"read_through_row": readEnd,
"data_extends_through_row": regionEnd,
"unread_rows": regionEnd - readEnd,
"reread_range": cr,
}
}
}
// Drop the fields whose information rows-json fully carries elsewhere:
// - annotated_csv / row_indices / col_indices → reconstructed into
// columns + rows (with integer row_number), losslessly.
// - warning_message → its two halves are both handled: the static
// "[row=N] / col_indices[j]" parse nag is moot once those fields exist,
// and the dynamic "数据没读全" half is now the structured
// data_not_fully_read field above. (Confirmed against the backend's
// get-range-as-csv.ts — warning_message has no other content.)
delete(result, "annotated_csv")
delete(result, "row_indices")
delete(result, "col_indices")
delete(result, "warning_message")
return result
}
// a1EndRow extracts the ending row number from an A1 range, e.g. "A1:N51" → 51,
// "Sheet1!B2:D9" → 9, "C5" → 5. Returns 0 when no row number is present.
func a1EndRow(rng string) int {
@@ -377,89 +235,6 @@ func a1EndRow(rng string) int {
return n
}
// parseCSVRecord parses a single logical CSV record (which may span multiple
// physical lines via quoted embedded newlines) into its fields. An empty record
// yields no fields; a malformed record falls back to a naive comma split so a
// stray quote never drops a whole row.
func parseCSVRecord(text string) []string {
if strings.TrimSpace(text) == "" {
return nil
}
r := csv.NewReader(strings.NewReader(text))
r.FieldsPerRecord = -1
fields, err := r.Read()
if err != nil {
return strings.Split(text, ",")
}
return fields
}
// coerceStringSlice returns v as []string when it is a homogeneous []interface{}
// of strings (the shape of the tool's col_indices), else nil.
func coerceStringSlice(v interface{}) []string {
arr, ok := v.([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, e := range arr {
s, ok := e.(string)
if !ok {
return nil
}
out = append(out, s)
}
return out
}
// csvStartColIndex returns the 0-based column index of a range's start column,
// e.g. "A1:K3380" → 0, "C5:F9" → 2, "Sheet1!D2" → 3. Unparseable input → 0.
func csvStartColIndex(rng string) int {
rng = strings.TrimSpace(rng)
if i := strings.LastIndex(rng, "!"); i >= 0 {
rng = rng[i+1:]
}
var letters strings.Builder
for _, c := range rng {
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') {
letters.WriteRune(c)
continue
}
break
}
if letters.Len() == 0 {
return 0
}
return csvColToIndex(letters.String())
}
// csvColToIndex converts a column letter to its 0-based index ("A"→0, "K"→10,
// "AA"→26). Non-letter input → -1.
func csvColToIndex(s string) int {
n := 0
for _, c := range strings.ToUpper(s) {
if c < 'A' || c > 'Z' {
break
}
n = n*26 + int(c-'A'+1)
}
return n - 1
}
// csvColLetter converts a 0-based column index back to its letter (0→"A",
// 25→"Z", 26→"AA"). Negative input → "".
func csvColLetter(idx int) string {
if idx < 0 {
return ""
}
var b []byte
for idx >= 0 {
b = append([]byte{byte('A' + idx%26)}, b...)
idx = idx/26 - 1
}
return string(b)
}
// DropdownGet wraps get_cell_ranges scoped to data_validation: read the
// dropdown configuration on a range. Aligned with its sibling +cells-get
// — sheet selection is via --sheet-id / --sheet-name (XOR), and --range

View File

@@ -63,20 +63,6 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
"value_render_option": "formatted_value",
},
},
{
// --rows-json is post-processing on +csv-get's response; it must
// NOT leak into the get_range_as_csv input.
name: "+csv-get --rows-json builds the same input (flag is post-process)",
sc: CsvGet,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--rows-json"},
toolName: "get_range_as_csv",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "A1:C10",
"max_rows": float64(unboundedReadLimit),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -179,113 +165,3 @@ func TestCsvGet_StripRowPrefix(t *testing.T) {
t.Errorf("other field corrupted: %v", out["other"])
}
}
// TestAssembleRowsJSON covers the --rows-json reshaping: every logical row
// emitted (no header singled out), integer row_number, column-letter keyed
// values, embedded newlines inside quoted fields, and current_region passthrough.
func TestAssembleRowsJSON(t *testing.T) {
t.Parallel()
in := map[string]interface{}{
"annotated_csv": "[row=1] 姓名,备注,时间差_分钟\n[row=2] 张三,\"line1\nline2\",8.5\n[row=3] 李四,ok,3",
"current_region": "A1:C3",
"col_indices": []interface{}{"A", "B", "C"},
"row_indices": []interface{}{1, 2, 3},
"warning_message": "①定位行号…②定位列字母…",
}
out, ok := assembleRowsJSON(in, "A1:C3").(map[string]interface{})
if !ok {
t.Fatalf("assembleRowsJSON did not return a map")
}
// Fields whose info rows-json carries elsewhere are dropped (annotated_csv /
// indices → rows; warning_message → moot static nag + structured
// data_not_fully_read). Unrelated metadata like current_region is preserved.
if _, exists := out["annotated_csv"]; exists {
t.Errorf("annotated_csv should be dropped")
}
if _, exists := out["col_indices"]; exists {
t.Errorf("col_indices should be dropped")
}
if _, exists := out["warning_message"]; exists {
t.Errorf("warning_message should be dropped in rows-json mode")
}
if _, exists := out["columns"]; exists {
t.Errorf("columns field should not exist (no header assumption)")
}
if out["current_region"] != "A1:C3" {
t.Errorf("current_region passthrough lost: %v", out["current_region"])
}
rows, _ := out["rows"].([]map[string]interface{})
if len(rows) != 3 {
t.Fatalf("want all 3 rows (incl. row 1), got %d: %+v", len(rows), rows)
}
// Row 1 is emitted as a normal row, not consumed as a header.
if rows[0]["row_number"].(int) != 1 {
t.Errorf("first row_number = %v, want 1", rows[0]["row_number"])
}
if v := rows[0]["values"].(map[string]interface{}); v["A"] != "姓名" || v["C"] != "时间差_分钟" {
t.Errorf("row 1 values wrong: %+v", v)
}
// Row 2 keeps its embedded newline inside a single cell.
v1 := rows[1]["values"].(map[string]interface{})
if rows[1]["row_number"].(int) != 2 || v1["A"] != "张三" || v1["B"] != "line1\nline2" || v1["C"] != "8.5" {
t.Errorf("row 2 wrong (embedded newline?): %+v", rows[1])
}
}
// TestAssembleRowsJSON_DerivedLetters verifies cell letters are derived from the
// range start when the tool omits col_indices (e.g. a C-anchored read).
func TestAssembleRowsJSON_DerivedLetters(t *testing.T) {
t.Parallel()
in := map[string]interface{}{
"annotated_csv": "[row=5] h1,h2\n[row=6] a,b",
}
out := assembleRowsJSON(in, "C5:D6").(map[string]interface{})
rows := out["rows"].([]map[string]interface{})
if len(rows) != 2 {
t.Fatalf("want 2 rows, got %d", len(rows))
}
if rows[0]["row_number"].(int) != 5 {
t.Errorf("first row_number = %v, want 5", rows[0]["row_number"])
}
if v := rows[0]["values"].(map[string]interface{}); v["C"] != "h1" || v["D"] != "h2" {
t.Errorf("derived-letter values wrong: %+v", v)
}
if v := rows[1]["values"].(map[string]interface{}); v["C"] != "a" || v["D"] != "b" {
t.Errorf("row 6 values wrong: %+v", v)
}
}
// TestAssembleRowsJSON_DataNotFullyRead verifies the structured under-read hint:
// when current_region extends past actual_range, rows-json surfaces the true data
// range as a first-class field (mirroring the backend's prose warning).
func TestAssembleRowsJSON_DataNotFullyRead(t *testing.T) {
t.Parallel()
// Read only A1:D2, but the data region reaches D4 → 2 rows unread.
in := map[string]interface{}{
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
"actual_range": "A1:D2",
"current_region": "A1:D4",
}
out := assembleRowsJSON(in, "A1:D2").(map[string]interface{})
hint, ok := out["data_not_fully_read"].(map[string]interface{})
if !ok {
t.Fatalf("data_not_fully_read missing; out=%+v", out)
}
if hint["read_through_row"] != 2 || hint["data_extends_through_row"] != 4 ||
hint["unread_rows"] != 2 || hint["reread_range"] != "A1:D4" {
t.Errorf("data_not_fully_read wrong: %+v", hint)
}
// Fully-read case: no hint emitted.
in2 := map[string]interface{}{
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
"actual_range": "A1:D2",
"current_region": "A1:D2",
}
out2 := assembleRowsJSON(in2, "A1:D2").(map[string]interface{})
if _, exists := out2["data_not_fully_read"]; exists {
t.Errorf("data_not_fully_read should be absent when fully read")
}
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_recover ───────────────────────────────────────────────
//
// Wraps:
// - recover_to_revision (write) — powers +recover
//
// Rolls the WHOLE spreadsheet back to a past revision (the undo design doc's
// "方案 B"). Unlike +undo — which is precise, per-edit, and scoped to this CLI
// link — +recover is a full-document version restore. The facade gateway
// already owns this capability (the same revert-by-revision path the web
// "history" panel drives): it submits a single RECOVER changeset that reverts
// every sheet to the target revision and produces a new revision. The CLI only
// passes the target revision; all the work stays server-side.
//
// ⚠️ Full-table overwrite: +recover discards EVERY change made after
// --to-revision, including other collaborators' (and the web UI's) edits. Use
// it only on agent scratch spreadsheets, or when a whole-document rollback is
// acceptable. For precise, this-link-only undo, use +undo instead.
var Recover = common.Shortcut{
Service: "sheets",
Command: "+recover",
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("+recover"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
_, err = recoverInput(runtime, token)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := recoverInput(runtime, token)
return invokeToolDryRun(token, ToolKindWrite, "recover_to_revision", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
input, err := recoverInput(runtime, token)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "recover_to_revision", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"+recover is a FULL-DOCUMENT rollback — it discards every edit made after --to-revision, including other collaborators'. For precise, this-link-only undo, use +undo instead.",
"--to-revision takes a revision number returned by a prior write (the `revision` field in the response).",
"Use --dry-run to preview the recover request before running it.",
},
}
// recoverInput builds the recover_to_revision tool body. Network-free; shared
// by Validate, DryRun, and Execute.
func recoverInput(runtime flagView, token string) (map[string]interface{}, error) {
rev := runtime.Int("to-revision")
if rev < 1 {
return nil, common.FlagErrorf("--to-revision must be a positive revision number")
}
return map[string]interface{}{
"excel_id": token,
"to_revision": rev,
}, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,991 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
// ─── pure helpers: date serial, typed cell mapping ────────────────────
func TestTablePut_IsoDateToSerial(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want int
ok bool
}{
{"2024-01-15", 45306, true}, // the empirically verified anchor
{"2024-01-01", 45292, true},
{"2024-02-29", 45351, true}, // 2024 is a leap year
{"1899-12-31", 1, true}, // one day after the epoch
{"not-a-date", 0, false},
{"2024/01/15", 0, false}, // wrong separator
}
for _, tt := range cases {
got, err := isoDateToSerial(tt.in)
if tt.ok {
if err != nil {
t.Errorf("isoDateToSerial(%q) unexpected error: %v", tt.in, err)
continue
}
if got != tt.want {
t.Errorf("isoDateToSerial(%q) = %d, want %d", tt.in, got, tt.want)
}
} else if err == nil {
t.Errorf("isoDateToSerial(%q) = %d, want error", tt.in, got)
}
}
}
func TestTablePut_BuildTypedCell(t *testing.T) {
t.Parallel()
t.Run("string keeps literal + text format so digit-like ids survive read-back", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "id", Type: "string"}, "00123")
if err != nil {
t.Fatal(err)
}
if cell["value"] != "00123" {
t.Errorf("value = %#v, want \"00123\"", cell["value"])
}
if nf := numberFormatOf(cell); nf != "@" {
t.Errorf("number_format = %q, want @ (text format so +table-get infers string, not number)", nf)
}
})
t.Run("string stringifies a json.Number without scientific notation", func(t *testing.T) {
t.Parallel()
cell, _ := buildTypedCell(&tableColumnSpec{Name: "code", Type: "string"}, json.Number("123456789012345"))
if cell["value"] != "123456789012345" {
t.Errorf("value = %#v, want literal digits", cell["value"])
}
})
t.Run("number preserves json.Number", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "amt", Type: "number", Format: "#,##0"}, json.Number("259874"))
if err != nil {
t.Fatal(err)
}
if n, ok := cell["value"].(json.Number); !ok || n.String() != "259874" {
t.Errorf("value = %#v, want json.Number 259874", cell["value"])
}
if nf := numberFormatOf(cell); nf != "#,##0" {
t.Errorf("number_format = %q, want #,##0", nf)
}
})
t.Run("date converts to serial + default format", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date"}, "2024-01-15")
if err != nil {
t.Fatal(err)
}
if cell["value"] != 45306 {
t.Errorf("value = %#v, want serial 45306", cell["value"])
}
if nf := numberFormatOf(cell); nf != "yyyy-mm-dd" {
t.Errorf("number_format = %q, want default yyyy-mm-dd", nf)
}
})
t.Run("date honors explicit format", func(t *testing.T) {
t.Parallel()
cell, _ := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date", Format: "yyyy-mm"}, "2024-01-15")
if nf := numberFormatOf(cell); nf != "yyyy-mm" {
t.Errorf("number_format = %q, want yyyy-mm", nf)
}
})
t.Run("bool maps to boolean", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "b", Type: "bool"}, true)
if err != nil || cell["value"] != true {
t.Errorf("value = %#v (err=%v), want true", cell["value"], err)
}
})
t.Run("null is an empty cell that still carries format", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date"}, nil)
if err != nil {
t.Fatal(err)
}
if _, has := cell["value"]; has {
t.Errorf("null cell should have no value: %#v", cell)
}
if nf := numberFormatOf(cell); nf != "yyyy-mm-dd" {
t.Errorf("null date cell should still carry format, got %q", nf)
}
})
t.Run("type mismatches are rejected", func(t *testing.T) {
t.Parallel()
if _, err := buildTypedCell(&tableColumnSpec{Type: "number"}, "abc"); err == nil {
t.Error("number column accepting a string should error")
}
if _, err := buildTypedCell(&tableColumnSpec{Type: "date"}, json.Number("1")); err == nil {
t.Error("date column accepting a number should error")
}
if _, err := buildTypedCell(&tableColumnSpec{Type: "bool"}, "true"); err == nil {
t.Error("bool column accepting a string should error")
}
})
}
// numberFormatOf digs the number_format out of a built cell's cell_styles, or
// "" when absent.
func numberFormatOf(cell map[string]interface{}) string {
styles, ok := cell["cell_styles"].(map[string]interface{})
if !ok {
return ""
}
nf, _ := styles["number_format"].(string)
return nf
}
// ─── payload validation ───────────────────────────────────────────────
func TestTablePut_PayloadValidation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
json string
want string
}{
{"empty sheets", `{"sheets":[]}`, "at least one sheet"},
{"missing name", `{"sheets":[{"columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "name is required"},
{"duplicate name", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[]},{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "duplicate sheet name"},
{"no columns", `{"sheets":[{"name":"S","columns":[],"rows":[]}]}`, "columns must be non-empty"},
{"bad column type", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"timestamp"}],"rows":[]}]}`, "invalid type"},
{"column missing name", `{"sheets":[{"name":"S","columns":[{"type":"string"}],"rows":[]}]}`, "columns[0].name is required"},
{"row width mismatch", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"},{"name":"b","type":"string"}],"rows":[["x"]]}]}`, "column count"},
{"bad start_cell", `{"sheets":[{"name":"S","start_cell":"A","columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "start_cell"},
{"bad date value", `{"sheets":[{"name":"S","columns":[{"name":"d","type":"date"}],"rows":[["2025/03/31"]]}]}`, "must be ISO"},
{"number expects numeric", `{"sheets":[{"name":"S","columns":[{"name":"n","type":"number"}],"rows":[["abc"]]}]}`, "number expects"},
{"invalid json", `{not json`, "invalid JSON"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := parseTablePutPayload(stubFlagView{"sheets": tt.json})
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Errorf("want error containing %q, got %v", tt.want, err)
}
})
}
}
// stubFlagView is a minimal flagView backed by a map, for unit-testing the
// payload parser without a cobra command.
type stubFlagView map[string]string
func (s stubFlagView) Str(name string) string { return s[name] }
func (s stubFlagView) Bool(name string) bool { return s[name] == "true" }
func (s stubFlagView) Int(name string) int { return 0 }
func (s stubFlagView) Float64(name string) float64 { return 0 }
func (s stubFlagView) Changed(name string) bool { _, ok := s[name]; return ok }
func (s stubFlagView) StrArray(name string) []string { return nil }
func (s stubFlagView) StrSlice(name string) []string { return nil }
func (s stubFlagView) Command() string { return "+table-put" }
// ─── dry-run: create + write rendering ────────────────────────────────
const tablePutSheetsJSON = `{"sheets":[{"name":"月度","columns":[` +
`{"name":"门店","type":"string"},` +
`{"name":"月份","type":"date","format":"yyyy-mm"},` +
`{"name":"销售额","type":"number","format":"#,##0"}` +
`],"rows":[["北京","2024-01-15",259874]]}]}`
func TestTablePut_DryRunWrite(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL, "--sheets", tablePutSheetsJSON})
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1 (set_cell_range only)", len(calls))
}
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
if input["excel_id"] != testToken {
t.Errorf("excel_id = %v, want %s", input["excel_id"], testToken)
}
if input["sheet_name"] != "月度" {
t.Errorf("sheet_name = %v, want 月度", input["sheet_name"])
}
if input["range"] != "A1:C2" {
t.Errorf("range = %v, want A1:C2 (1 header + 1 data row × 3 cols)", input["range"])
}
rows := input["cells"].([]interface{})
header := rows[0].([]interface{})
if hs := cellStyles(header[0]); hs["font_weight"] != "bold" {
t.Errorf("header cell should be bold, got %#v", header[0])
}
data := rows[1].([]interface{})
// 月份 (date) → serial 45306, number_format yyyy-mm
if v := cellValue(data[1]); v != float64(45306) {
t.Errorf("date cell value = %#v, want 45306 serial", v)
}
if nf := cellStyles(data[1])["number_format"]; nf != "yyyy-mm" {
t.Errorf("date number_format = %v, want yyyy-mm", nf)
}
// 销售额 (number) → 259874 preserved
if v := cellValue(data[2]); v != float64(259874) {
t.Errorf("number cell value = %#v, want 259874", v)
}
}
func cellValue(c interface{}) interface{} {
m, _ := c.(map[string]interface{})
return m["value"]
}
func cellStyles(c interface{}) map[string]interface{} {
m, _ := c.(map[string]interface{})
s, _ := m["cell_styles"].(map[string]interface{})
return s
}
// ─── validation through the cobra surface ─────────────────────────────
func TestTablePut_Validation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
want string
}{
{
name: "missing spreadsheet locator rejected",
args: []string{"--sheets", tablePutSheetsJSON},
want: "at least one",
},
{
name: "url and token are mutually exclusive",
args: []string{"--url", testURL, "--spreadsheet-token", testToken, "--sheets", tablePutSheetsJSON},
want: "mutually exclusive",
},
{
name: "bad column type rejected",
args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"foo"}],"rows":[]}]}`},
want: "invalid type",
},
{
name: "row width mismatch rejected",
args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"},{"name":"b","type":"string"}],"rows":[["only-one"]]}]}`},
want: "column count",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, TablePut, append(tt.args, "--dry-run"))
if err == nil {
t.Fatalf("expected validation error; got nil. stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), tt.want) {
t.Errorf("error missing %q; got=%s|%s|%v", tt.want, stdout, stderr, err)
}
})
}
}
// ─── execute paths with stubbed tools ─────────────────────────────────
// TestTablePut_ExecuteWrite drives the write path: a structure read maps the
// existing sheet by name, then a set_cell_range write fills it.
func TestTablePut_ExecuteWrite(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"数据","index":0}]}`)
write := toolOutputStub(testToken, "write", `{"updated_cells_count":2}`)
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"数据","columns":[{"name":"a","type":"string"},{"name":"b","type":"number"}],"rows":[["x",1]]}]}`},
structure, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 1 {
t.Fatalf("result sheets = %d, want 1: %#v", len(sheets), data)
}
s0, _ := sheets[0].(map[string]interface{})
if s0["name"] != "数据" || s0["sheet_id"] != testSheetID {
t.Errorf("sheet summary = %#v, want name=数据 sheet_id=%s", s0, testSheetID)
}
if s0["range"] != "A1:B2" {
t.Errorf("range = %v, want A1:B2", s0["range"])
}
}
// TestTablePut_ExecuteWriteCreatesMissingSheet covers the branch where the
// named sheet does not yet exist: a create precedes the write.
func TestTablePut_ExecuteWriteCreatesMissingSheet(t *testing.T) {
t.Parallel()
// First structure read sees only "Sheet1"; the payload targets "新表", so
// createSheet runs, and the follow-up read (FIFO: second stub) resolves the
// newly created sheet's id.
structBefore := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0}]}`)
structAfter := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"新表","index":1}]}`)
write := toolOutputStub(testToken, "write", `{"ok":true}`)
write.Reusable = true // modify_workbook_structure create + set_cell_range
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"新表","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`},
structBefore, structAfter, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 1 {
t.Fatalf("result sheets = %d, want 1", len(sheets))
}
if s0, _ := sheets[0].(map[string]interface{}); s0["sheet_id"] != testSheetID2 {
t.Errorf("created sheet id = %v, want %s", s0["sheet_id"], testSheetID2)
}
}
// TestTablePut_SheetCreateDims checks new-sheet sizing: small tables keep the
// 20×200 floor (unchanged behavior), wide/long tables grow past it (the fix for
// set_cell_range "exceeds sheet bounds"), and start_cell offset + header row are
// accounted for, with columns clamped to the backend's 200 ceiling.
func TestTablePut_SheetCreateDims(t *testing.T) {
t.Parallel()
bp := func(b bool) *bool { return &b }
cols := func(n int) []tableColumnSpec { return make([]tableColumnSpec, n) }
rows := func(n int) [][]interface{} { return make([][]interface{}, n) }
cases := []struct {
name string
spec tableSheetSpec
wantRows, wantCols int
}{
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, 200, 20},
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, 200, 37},
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, 501, 20},
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, 200, 42},
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, 500, 20},
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, 200, 200},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotRows, gotCols := sheetCreateDims(&tt.spec)
if gotRows != tt.wantRows || gotCols != tt.wantCols {
t.Errorf("sheetCreateDims = (%d rows, %d cols), want (%d, %d)", gotRows, gotCols, tt.wantRows, tt.wantCols)
}
})
}
}
// TestTablePut_ExecuteCreatesWideSheetWithDims is the regression test for the
// wide-table bug: a 25-column payload targeting a not-yet-existing sheet must
// create it with 25 columns (past the 20-column default) so the follow-up
// set_cell_range fits instead of failing with "exceeds sheet bounds".
func TestTablePut_ExecuteCreatesWideSheetWithDims(t *testing.T) {
t.Parallel()
structBefore := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0}]}`)
createStub := toolOutputStub(testToken, "write", `{"ok":true}`) // modify_workbook_structure create
structAfter := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"宽表","index":1}]}`)
writeStub := toolOutputStub(testToken, "write", `{"ok":true}`) // set_cell_range
const n = 25
cols := strings.TrimRight(strings.Repeat(`{"name":"c","type":"string"},`, n), ",")
vals := strings.TrimRight(strings.Repeat(`"x",`, n), ",")
payload := `{"sheets":[{"name":"宽表","columns":[` + cols + `],"rows":[[` + vals + `]]}]}`
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets", payload},
structBefore, createStub, structAfter, writeStub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
var wire map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &wire); err != nil {
t.Fatalf("decode create body: %v", err)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
t.Fatalf("decode create tool input: %v", err)
}
if input["operation"] != "create" {
t.Fatalf("first write should be the create op, got %#v", input["operation"])
}
if input["columns"] != float64(n) {
t.Errorf("create columns = %#v, want %d (sized to the wide payload)", input["columns"], n)
}
if input["rows"] != float64(200) {
t.Errorf("create rows = %#v, want 200 (floor)", input["rows"])
}
}
// TestTablePut_ExecutePartialFailure covers the partial-success error path:
// a set_cell_range write fails mid-import and the structured error surfaces.
// TestTablePut_ExecuteTotalFailure: a single sheet whose write fails landed
// nothing — it must be a plain failure, NOT partial_success.
func TestTablePut_ExecuteTotalFailure(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"数据","index":0}]}`)
writeErr := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_write",
Body: map[string]interface{}{"code": 1254000, "msg": "boom"},
}
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"数据","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`},
structure, writeErr)
if err == nil {
t.Fatalf("expected failure; got nil. out=%s", out)
}
if strings.Contains(err.Error(), "partially applied") || strings.Contains(out, "partially applied") {
t.Errorf("single-sheet failure must NOT be partial_success; got err=%v out=%s", err, out)
}
if !strings.Contains(err.Error(), "failed") && !strings.Contains(out, "no sheets were written") {
t.Errorf("expected plain-failure message; got err=%v out=%s", err, out)
}
}
// TestTablePut_ExecutePartialFailure: first sheet's write lands, second fails →
// partial_success carrying the first sheet in written_sheets.
func TestTablePut_ExecutePartialFailure(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read",
`{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"汇总","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"明细","index":1}]}`)
writeOK := toolOutputStub(testToken, "write", `{"updated_cells_count":2}`)
writeErr := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_write",
Body: map[string]interface{}{"code": 1254000, "msg": "boom"},
}
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"汇总","columns":[{"name":"a","type":"string"}],"rows":[["x"]]},{"name":"明细","columns":[{"name":"a","type":"string"}],"rows":[["y"]]}]}`},
structure, writeOK, writeErr)
if err == nil {
t.Fatalf("expected partial-success error; got nil. out=%s", out)
}
if !strings.Contains(err.Error(), "partially applied") && !strings.Contains(out, "partially applied") {
t.Errorf("expected partial_success (not total failure); got err=%v out=%s", err, out)
}
// The failing sheet is named in the message; the written one lives in the
// structured written_sheets detail.
if !strings.Contains(err.Error(), "明细") {
t.Errorf("partial_success should name the failed sheet 明细; got err=%v", err)
}
}
// ─── +workbook-create typed --sheets path ─────────────────────────────
// TestWorkbookCreate_TypedMutualExclusion locks the Validate contract: the typed
// --sheets entry can't be combined with the untyped --headers/--values.
func TestWorkbookCreate_TypedMutualExclusion(t *testing.T) {
t.Parallel()
typed := `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`
for _, tc := range []struct {
name string
args []string
}{
{"sheets+headers", []string{"--title", "X", "--sheets", typed, "--headers", `["a"]`}},
{"sheets+values", []string{"--title", "X", "--sheets", typed, "--values", `[["x"]]`}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, tc.args)
if err == nil {
t.Fatalf("expected mutual-exclusion error; got nil (stderr=%s)", stderr)
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("want 'mutually exclusive' error; got %v", err)
}
})
}
}
// TestWorkbookCreate_EmptySheetsErrors locks the fix for an explicitly-given but
// empty --sheets (e.g. empty stdin / file): it must error, not silently fall
// through to creating an empty workbook.
func TestWorkbookCreate_EmptySheetsErrors(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, []string{"--title", "X", "--sheets", ""})
if err == nil {
t.Fatalf("expected error for empty --sheets; got nil (stderr=%s)", stderr)
}
if !strings.Contains(err.Error(), "empty") {
t.Errorf("want 'empty' error; got %v", err)
}
}
// TestWorkbookCreate_TypedAdoptsDefaultSheet covers the one-step typed create:
// the new workbook's default sheet is renamed to the first payload sheet's name
// and reused (no empty Sheet1 left behind), then written type-faithfully (the
// date lands as an Excel serial, not text).
func TestWorkbookCreate_TypedAdoptsDefaultSheet(t *testing.T) {
t.Parallel()
create := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{"spreadsheet_token": "shtTYPED", "title": "Demo"},
},
},
}
// lookupFirstSheetID and writeTypedSheets' listSheetIDsByName both read the
// structure; one reusable stub serves both, reporting only the default sheet.
structure := toolOutputStub("shtTYPED", "read", `{"sheets":[{"sheet_id":"shtDef","sheet_name":"Sheet1","index":0}]}`)
structure.Reusable = true
rename := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/shtTYPED/tools/invoke_write",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), "modify_workbook_structure") },
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"output": `{"ok":true}`}},
}
write := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/shtTYPED/tools/invoke_write",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), "set_cell_range") },
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"output": `{"updated_cells_count":4}`}},
}
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
"--title", "Demo",
"--sheets", `{"sheets":[{"name":"Sales","columns":[{"name":"d","type":"date"},{"name":"amt","type":"number"}],"rows":[["2024-01-15",1234.5]]}]}`,
}, create, structure, rename, write)
if err != nil {
t.Fatalf("typed create failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtTYPED" {
t.Errorf("spreadsheet_token = %v, want shtTYPED", data["spreadsheet"])
}
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
t.Fatalf("want 1 written sheet, got %#v", data["sheets"])
}
// Default sheet adopted: rename targets shtDef → "Sales" (no new sheet, no
// stray Sheet1).
renameInput := decodeToolInput(t, decodeRawEnvelopeBody(t, rename.CapturedBody), "modify_workbook_structure")
if renameInput["operation"] != "rename" || renameInput["sheet_id"] != "shtDef" || renameInput["new_name"] != "Sales" {
t.Errorf("rename should adopt default shtDef→Sales; got %#v", renameInput)
}
// The data write carries the date as serial 45306, proving the type-faithful path.
writeInput := decodeToolInput(t, decodeRawEnvelopeBody(t, write.CapturedBody), "set_cell_range")
cellsJSON, _ := json.Marshal(writeInput["cells"])
if !strings.Contains(string(cellsJSON), "45306") {
t.Errorf("date 2024-01-15 should be written as serial 45306; cells=%s", cellsJSON)
}
}
// TestWorkbookCreate_TypedDryRun verifies the dry-run previews create + a typed
// set_cell_range write with the date already converted to a serial.
func TestWorkbookCreate_TypedDryRun(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "Demo",
"--sheets", `{"sheets":[{"name":"S","columns":[{"name":"d","type":"date"}],"rows":[["2024-01-15"]]}]}`,
})
if len(calls) != 2 {
t.Fatalf("want 2 dry-run calls (create + typed write), got %d", len(calls))
}
raw, _ := json.Marshal(calls[1])
if !strings.Contains(string(raw), "45306") {
t.Errorf("typed dry-run write should contain serial 45306; got %s", raw)
}
}
func TestTablePut_StringifyCellValue(t *testing.T) {
t.Parallel()
cases := []struct {
in interface{}
want string
}{
{"plain", "plain"},
{json.Number("12345678901234"), "12345678901234"},
{true, "TRUE"},
{false, "FALSE"},
{3.5, "3.5"},
}
for _, tt := range cases {
if got := stringifyCellValue(tt.in); got != tt.want {
t.Errorf("stringifyCellValue(%#v) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestTablePut_DescribeJSONType(t *testing.T) {
t.Parallel()
cases := []struct {
in interface{}
want string
}{
{"x", "a string"},
{json.Number("1"), "a number"},
{true, "a boolean"},
{[]interface{}{}, "an array"},
{map[string]interface{}{}, "an object"},
{3.14, "float64"},
}
for _, tt := range cases {
if got := describeJSONType(tt.in); got != tt.want {
t.Errorf("describeJSONType(%#v) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestTablePut_HeaderAndMode(t *testing.T) {
t.Parallel()
bp := func(b bool) *bool { return &b }
// headerOn: overwrite writes header, append omits it by default, explicit wins
if !headerOn(&tableSheetSpec{}) {
t.Error("overwrite default should write header")
}
if headerOn(&tableSheetSpec{Mode: "append"}) {
t.Error("append should omit header by default")
}
if !headerOn(&tableSheetSpec{Mode: "append", Header: bp(true)}) {
t.Error("explicit header:true should override append default")
}
if headerOn(&tableSheetSpec{Header: bp(false)}) {
t.Error("explicit header:false should be honored")
}
// writeModeName
if writeModeName(&tableSheetSpec{}) != "overwrite" || writeModeName(&tableSheetSpec{Mode: "append"}) != "append" {
t.Error("writeModeName normalization wrong")
}
// buildSheetMatrix header toggle
s := &tableSheetSpec{Columns: []tableColumnSpec{{Name: "a", Type: "string"}}, Rows: [][]interface{}{{"x"}}}
if m, _ := buildSheetMatrix(s, true, false); len(m) != 1 {
t.Errorf("header off → 1 data row, got %d", len(m))
}
if m, _ := buildSheetMatrix(s, true, true); len(m) != 2 {
t.Errorf("header on → header + 1 data row, got %d", len(m))
}
}
func TestTablePut_BadModeRejected(t *testing.T) {
t.Parallel()
_, err := parseTablePutPayload(stubFlagView{"sheets": `{"sheets":[{"name":"S","mode":"upsert","columns":[{"name":"a","type":"string"}],"rows":[]}]}`})
if err == nil || !strings.Contains(err.Error(), "invalid") {
t.Errorf("mode \"upsert\" should be rejected, got %v", err)
}
}
// TestTablePut_AppendEmptySheetWritesHeader: appending to an EMPTY sheet still
// writes the header row, so column names aren't lost (and a later +table-get
// won't consume the first data row as the header).
func TestTablePut_AppendEmptySheetWritesHeader(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"新","index":0}]}`)
region := toolOutputStub(testToken, "read", `{}`) // empty sheet: no current_region → lastRow 0
write := toolOutputStub(testToken, "write", `{"ok":true}`)
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"新","mode":"append","columns":[{"name":"列A","type":"string"}],"rows":[["x"],["y"]]}]}`},
structure, region, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
var wire map[string]interface{}
if err := json.Unmarshal(write.CapturedBody, &wire); err != nil {
t.Fatalf("decode captured write body: %v", err)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
t.Fatalf("decode tool input: %v", err)
}
cells, _ := input["cells"].([]interface{})
if len(cells) != 3 {
t.Fatalf("empty-sheet append should write header + 2 data rows = 3, got %d", len(cells))
}
if header, _ := cells[0].([]interface{}); len(header) > 0 {
if h0, _ := header[0].(map[string]interface{}); h0["value"] != "列A" {
t.Errorf("first row should be the header 列A; got %#v", h0)
}
}
if input["range"] != "A1:A3" {
t.Errorf("range = %v, want A1:A3 (header + 2 rows at top of empty sheet)", input["range"])
}
}
// TestTablePut_ExecuteAppend verifies append placement: data lands below the
// sheet's existing data (current_region A1:B5 → start at row 6) with no repeated
// header.
func TestTablePut_ExecuteAppend(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"日志","index":0}]}`)
region := toolOutputStub(testToken, "read", `{"current_region":"A1:B5","actual_range":"A1:B5"}`)
write := toolOutputStub(testToken, "write", `{"ok":true}`)
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"日志","mode":"append","columns":[{"name":"时间","type":"string"},{"name":"值","type":"number"}],"rows":[["t1",1],["t2",2]]}]}`},
structure, region, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
// inspect the set_cell_range request the append produced
var wire map[string]interface{}
if err := json.Unmarshal(write.CapturedBody, &wire); err != nil {
t.Fatalf("decode captured write body: %v", err)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
t.Fatalf("decode tool input: %v", err)
}
if input["range"] != "A6:B7" {
t.Errorf("append range = %v, want A6:B7 (2 rows below last data row 5, no header)", input["range"])
}
if cells, _ := input["cells"].([]interface{}); len(cells) != 2 {
t.Errorf("append should write 2 data rows (no header), got %d", len(cells))
}
data := decodeEnvelopeData(t, out)
if s0, _ := data["sheets"].([]interface{})[0].(map[string]interface{}); s0["mode"] != "append" {
t.Errorf("summary mode = %v, want append", s0["mode"])
}
}
// TestTablePut_HeaderFalseAndAllowOverwrite checks header:false drops the
// header row and allow_overwrite:false reaches the tool input.
func TestTablePut_HeaderFalseAndAllowOverwrite(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"S","header":false,"allow_overwrite":false,"columns":[{"name":"a","type":"string"}],"rows":[["x"],["y"]]}]}`})
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
if input["allow_overwrite"] != false {
t.Errorf("allow_overwrite = %v, want false", input["allow_overwrite"])
}
rows, _ := input["cells"].([]interface{})
if len(rows) != 2 {
t.Fatalf("header:false → 2 data rows only, got %d", len(rows))
}
first, _ := rows[0].([]interface{})[0].(map[string]interface{})
if first["value"] != "x" {
t.Errorf("header:false first cell = %v, want data 'x' (no header row)", first["value"])
}
}
// ─── +table-get ───────────────────────────────────────────────────────
func TestTableGet_SerialRoundTrip(t *testing.T) {
t.Parallel()
for _, iso := range []string{"2024-01-15", "2024-02-29", "2000-01-01", "1899-12-31"} {
s, err := isoDateToSerial(iso)
if err != nil {
t.Fatalf("isoDateToSerial(%s): %v", iso, err)
}
if back := serialToISO(float64(s)); back != iso {
t.Errorf("roundtrip %s → %d → %s", iso, s, back)
}
}
}
func TestTableGet_IsDateNumberFormat(t *testing.T) {
t.Parallel()
for _, nf := range []string{"yyyy-mm-dd", "yyyy-mm", "yyyy/m/d", "YYYY/MM/DD"} {
if !isDateNumberFormat(nf) {
t.Errorf("%q should be a date format", nf)
}
}
for _, nf := range []string{"#,##0", "0.00", "0.00%", "@", ""} {
if isDateNumberFormat(nf) {
t.Errorf("%q should not be a date format", nf)
}
}
}
func TestTableGet_InferColumnType(t *testing.T) {
t.Parallel()
mk := func(v interface{}, nf string) map[string]interface{} {
c := map[string]interface{}{"value": v}
if nf != "" {
c["cell_styles"] = map[string]interface{}{"number_format": nf}
}
return c
}
col := func(cells ...map[string]interface{}) [][]map[string]interface{} {
rows := make([][]map[string]interface{}, len(cells))
for i, c := range cells {
rows[i] = []map[string]interface{}{c}
}
return rows
}
if typ, f := inferColumnType(col(mk(45306.0, "yyyy-mm-dd")), 0); typ != "date" || f != "yyyy-mm-dd" {
t.Errorf("date col → %s/%s", typ, f)
}
if typ, f := inferColumnType(col(mk(100.0, "#,##0")), 0); typ != "number" || f != "#,##0" {
t.Errorf("number col → %s/%s", typ, f)
}
if typ, _ := inferColumnType(col(mk(true, "")), 0); typ != "bool" {
t.Errorf("bool col → %s", typ)
}
if typ, _ := inferColumnType(col(mk("x", "")), 0); typ != "string" {
t.Errorf("string col → %s", typ)
}
// digit-like value carrying text format (@) infers as string, not number —
// this is what makes +table-put's string columns (ids/postcodes) survive read-back.
if typ, _ := inferColumnType(col(mk(123.0, "@")), 0); typ != "string" {
t.Errorf("@-format numeric-looking col → %s, want string", typ)
}
if typ, _ := inferColumnType([][]map[string]interface{}{}, 0); typ != "string" {
t.Errorf("empty col → %s (want string)", typ)
}
// Mixed number+text degrades to string (self-consistent: every value is then
// a string), so the column round-trips and pandas doesn't choke. Numeric
// coercion of the dirty cells is left to the caller (pandas to_numeric).
if typ, _ := inferColumnType(col(mk(100.0, ""), mk("暂无", ""), mk(200.0, "")), 0); typ != "string" {
t.Errorf("mixed number+text col → %s, want string", typ)
}
// A bare number mixed into a date column must NOT stay date (would serial-
// convert the number into a bogus date) — degrades to string.
if typ, _ := inferColumnType(col(mk(45306.0, "yyyy-mm-dd"), mk(5.0, "")), 0); typ != "string" {
t.Errorf("date+bare-number col → %s, want string", typ)
}
}
func TestTableGet_CellToTyped(t *testing.T) {
t.Parallel()
mk := func(v interface{}) map[string]interface{} { return map[string]interface{}{"value": v} }
if v := cellToTyped(mk(45306.0), "date"); v != "2024-01-15" {
t.Errorf("date serial → %v, want 2024-01-15", v)
}
if v := cellToTyped(mk(100.0), "number"); v != 100.0 {
t.Errorf("number → %v", v)
}
if v := cellToTyped(mk(true), "bool"); v != true {
t.Errorf("bool → %v", v)
}
if v := cellToTyped(mk(""), "string"); v != nil {
t.Errorf("empty string → %v, want nil", v)
}
if v := cellToTyped(nil, "string"); v != nil {
t.Errorf("nil → %v, want nil", v)
}
if v := cellToTyped(mk("hi"), "string"); v != "hi" {
t.Errorf("string → %v", v)
}
}
// TestTableGet_DigitStringRoundTrip: a column +table-put wrote as string (text
// format @) reads back as string, not number — so leading-zero ids / postcodes
// survive instead of collapsing to a number.
func TestTableGet_DigitStringRoundTrip(t *testing.T) {
t.Parallel()
region := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+
`[{"value":"邮编"}],`+
`[{"value":"00123","cell_styles":{"number_format":"@"}}]`+
`]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-name", "S"}, region, cells)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
s0, _ := sheets[0].(map[string]interface{})
cols, _ := s0["columns"].([]interface{})
if c0, _ := cols[0].(map[string]interface{}); c0["type"] != "string" {
t.Errorf("@-format col 邮编 → type %v, want string", c0["type"])
}
rows, _ := s0["rows"].([]interface{})
if r0, _ := rows[0].([]interface{}); r0[0] != "00123" {
t.Errorf("value = %v, want \"00123\" (leading zero preserved)", r0[0])
}
}
// TestTableGet_ExecuteRoundTrip reads a sheet back and checks the output is the
// same typed protocol +table-put consumes: date serial → ISO, number preserved,
// types inferred from number_format.
func TestTableGet_ExecuteRoundTrip(t *testing.T) {
t.Parallel()
region := toolOutputStub(testToken, "read", `{"current_region":"A1:C2"}`)
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+
`[{"value":"门店"},{"value":"月份"},{"value":"销售额"}],`+
`[{"value":"北京"},{"value":45306,"cell_styles":{"number_format":"yyyy-mm"}},{"value":259874,"cell_styles":{"number_format":"#,##0"}}]`+
`]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-name", "销售"}, region, cells)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 1 {
t.Fatalf("want 1 sheet, got %d", len(sheets))
}
s0, _ := sheets[0].(map[string]interface{})
if s0["name"] != "销售" {
t.Errorf("name = %v, want 销售", s0["name"])
}
cols, _ := s0["columns"].([]interface{})
if len(cols) != 3 {
t.Fatalf("want 3 columns, got %d", len(cols))
}
c1, _ := cols[1].(map[string]interface{})
if c1["name"] != "月份" || c1["type"] != "date" || c1["format"] != "yyyy-mm" {
t.Errorf("col 月份 = %#v, want name=月份 date yyyy-mm", c1)
}
c2, _ := cols[2].(map[string]interface{})
if c2["type"] != "number" || c2["format"] != "#,##0" {
t.Errorf("col 销售额 = %#v, want number #,##0", c2)
}
rows, _ := s0["rows"].([]interface{})
r0, _ := rows[0].([]interface{})
if r0[1] != "2024-01-15" {
t.Errorf("date roundtrip = %v, want 2024-01-15 (serial 45306 → ISO)", r0[1])
}
if r0[2] != float64(259874) {
t.Errorf("number = %v, want 259874", r0[2])
}
}
func TestTableGet_DryRunIncludesCellRead(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, TableGet, []string{"--url", testURL, "--sheet-name", "S"})
found := false
for _, c := range calls {
body, _ := c.(map[string]interface{})["body"].(map[string]interface{})
if body == nil {
continue
}
if tn, _ := body["tool_name"].(string); tn == "get_cell_ranges" {
found = true
}
}
if !found {
t.Error("dry-run should include a get_cell_ranges read")
}
}
// TestTableGet_AllSheets covers the "read every sheet" path (no --sheet-name):
// get_workbook_structure lists sheets, then each is read in order.
func TestTableGet_AllSheets(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"s1","sheet_name":"A","index":0},{"sheet_id":"s2","sheet_name":"B","index":1}]}`)
regionA := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
cellsA := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"项"}],[{"value":"x"}]]}]}`)
regionB := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
cellsB := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"项"}],[{"value":"y"}]]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL}, structure, regionA, cellsA, regionB, cellsB)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 2 {
t.Fatalf("want 2 sheets (all), got %d", len(sheets))
}
got := []string{
sheets[0].(map[string]interface{})["name"].(string),
sheets[1].(map[string]interface{})["name"].(string),
}
if got[0] != "A" || got[1] != "B" {
t.Errorf("sheet names = %v, want [A B] in workbook order", got)
}
}

View File

@@ -6,19 +6,14 @@ package sheets
import (
"context"
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/drive"
)
// ─── lark_sheet_workbook ──────────────────────────────────────────────
@@ -540,6 +535,18 @@ var SheetSetTabColor = common.Shortcut{
},
}
// SheetShowGridline / SheetHideGridline toggle a sub-sheet's gridline display.
// Gridline show/hide is the same two-state-via-operation shape as
// +sheet-hide/+sheet-unhide (no --visible flag), so they reuse
// newSheetVisibilityShortcut; only the operation enum differs.
var SheetShowGridline = newSheetVisibilityShortcut(
"+sheet-show-gridline", "Show gridlines on a sub-sheet.", "show_gridline",
)
var SheetHideGridline = newSheetVisibilityShortcut(
"+sheet-hide-gridline", "Hide gridlines on a sub-sheet.", "hide_gridline",
)
// ─── +workbook-create (legacy OAPI, cli_status: cli-only) ────────────
//
// Creates a brand-new spreadsheet via POST /sheets/v3/spreadsheets, then
@@ -553,7 +560,7 @@ var SheetSetTabColor = common.Shortcut{
var WorkbookCreate = common.Shortcut{
Service: "sheets",
Command: "+workbook-create",
Description: "Create a new spreadsheet (optionally pre-filled with --headers and --values).",
Description: "Create a new spreadsheet, optionally pre-filled with untyped --headers/--values or typed --sheets (type-faithful one-step create + write).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
@@ -563,6 +570,20 @@ var WorkbookCreate = common.Shortcut{
if strings.TrimSpace(runtime.Str("title")) == "" {
return common.FlagErrorf("--title is required")
}
// --sheets (typed) is an alternative, mutually exclusive data entry to the
// untyped --headers/--values. Gated on Changed (not just non-empty): an
// explicitly-given but empty --sheets (e.g. empty stdin / file) is an
// error, not a silent fall-through to creating an empty workbook.
if runtime.Changed("sheets") {
if strings.TrimSpace(runtime.Str("sheets")) == "" {
return common.FlagErrorf("--sheets was given but resolved to empty (empty stdin/file?); pass a typed payload, or drop --sheets to create an empty workbook")
}
if runtime.Str("headers") != "" || runtime.Str("values") != "" {
return common.FlagErrorf("--sheets is mutually exclusive with --headers/--values")
}
_, err := parseTablePutPayload(runtime)
return err
}
if runtime.Str("headers") != "" {
v, err := parseJSONFlag(runtime, "headers")
if err != nil {
@@ -598,6 +619,29 @@ var WorkbookCreate = common.Shortcut{
POST("/open-apis/sheets/v3/spreadsheets").
Desc("create spreadsheet").
Body(body)
// Typed --sheets path: preview the create POST, then one set_cell_range
// write per sheet (the first adopts the new workbook's default sheet).
// Mirrors +table-put's dry-run, against a placeholder token.
if runtime.Changed("sheets") {
if payload, err := parseTablePutPayload(runtime); err == nil {
headerStyle := runtime.Bool("header-style")
for i := range payload.Sheets {
s := &payload.Sheets[i]
matrix, _ := buildSheetMatrix(s, headerStyle, headerOn(s))
input := map[string]interface{}{
"excel_id": "<new-token>",
"sheet_name": s.Name,
"range": tablePutFullRange(s, len(matrix)),
"cells": matrix,
}
wireBody, _ := buildToolBody("set_cell_range", input)
dry.POST("/open-apis/sheet_ai/v2/spreadsheets/<new-token>/tools/invoke_write").
Desc(fmt.Sprintf("write typed sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), len(s.Columns))).
Body(wireBody)
}
}
return dry
}
if fill, _ := buildInitialFillInput(runtime); fill != nil {
fill["excel_id"] = "<new-token>"
fill["sheet_id"] = "<first-sheet-id>" // resolved from the workbook at execute time
@@ -628,6 +672,30 @@ var WorkbookCreate = common.Shortcut{
result := map[string]interface{}{"spreadsheet": ss}
// Typed --sheets path: write type-faithful data into the brand-new
// workbook, adopting its default sheet as the first payload sheet so no
// empty "Sheet1" is left behind. Mutually exclusive with --headers/--values
// (enforced in Validate).
if runtime.Changed("sheets") {
payload, err := parseTablePutPayload(runtime)
if err != nil {
return err // already validated; defensive
}
firstSheetID, err := lookupFirstSheetID(ctx, runtime, token)
if err != nil {
return workbookCreatedButFillFailed(token, ss,
fmt.Sprintf("resolving its default sheet for the typed write failed: %v", err))
}
written, err := writeTypedSheets(ctx, runtime, token, payload, runtime.Bool("header-style"), firstSheetID)
if err != nil {
return workbookCreatedButFillFailed(token, ss,
fmt.Sprintf("typed write failed: %v", err))
}
result["sheets"] = written
runtime.Out(result, nil)
return nil
}
// --headers / --values are optional. buildInitialFillInput returns
// (nil, nil) when both are absent or empty, in which case we skip the
// fill entirely rather than dereferencing a nil map.
@@ -657,6 +725,7 @@ var WorkbookCreate = common.Shortcut{
},
Tips: []string{
"--headers and --values are optional follow-up writes. They use the same set_cell_range tool as +cells-set; partial failure leaves the spreadsheet created but empty.",
"--sheets writes typed, type-faithful data (dates → real dates, numbers keep precision) in one step — the create + typed write that +table-put can't do on its own. Mutually exclusive with --headers/--values; the new workbook's default sheet becomes the first typed sheet (no empty Sheet1 left behind).",
},
}
@@ -770,178 +839,62 @@ var WorkbookExport = common.Shortcut{
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
ext := runtime.Str("file-extension")
if ext == "" {
ext = "xlsx"
}
body := map[string]interface{}{
"token": token,
"type": "sheet",
"file_extension": ext,
}
if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" {
body["sub_id"] = sid
}
dry := common.NewDryRunAPI().
POST("/open-apis/drive/v1/export_tasks").
Desc("create export task").
Body(body).
GET("/open-apis/drive/v1/export_tasks/<ticket>").
Desc("poll task status").
Params(map[string]interface{}{"token": token})
if strings.TrimSpace(runtime.Str("output-path")) != "" {
dry.GET("/open-apis/drive/v1/export_tasks/file/<file_token>/download").
Desc("download exported file")
}
return dry
p, _ := workbookExportParams(runtime)
p.OutputDir = strings.TrimSpace(runtime.Str("output-path"))
return drive.PlanExportDryRun(runtime, p)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
p, err := workbookExportParams(runtime)
if err != nil {
return err
}
ext := runtime.Str("file-extension")
if ext == "" {
ext = "xlsx"
}
body := map[string]interface{}{
"token": token,
"type": "sheet",
"file_extension": ext,
}
if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" {
body["sub_id"] = sid
}
taskData, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body)
if err != nil {
return err
}
ticket := common.GetString(taskData, "ticket")
if ticket == "" {
return output.Errorf(output.ExitAPI, "api_error", "export task created but ticket missing")
}
result := map[string]interface{}{
"ticket": ticket,
"file_extension": ext,
}
// Poll up to ~30s for completion.
var fileToken, fileName string
for attempt := 0; attempt < 15; attempt++ {
status, err := pollExportTask(runtime, token, ticket)
if err != nil {
return err
}
switch status.JobStatus {
case 0: // success
fileToken = status.FileToken
fileName = status.FileName
result["file_token"] = fileToken
result["file_name"] = fileName
result["file_size"] = status.FileSize
attempt = 999 // break outer loop
case 1, 2: // pending / in progress
time.Sleep(2 * time.Second)
continue
default: // any non-zero status outside the in-progress window is a failure
if status.JobErrorMsg != "" {
return output.Errorf(output.ExitAPI, "api_error", "export task %s failed: %s", ticket, status.JobErrorMsg)
}
return output.Errorf(output.ExitAPI, "api_error", "export task %s failed with job_status=%d", ticket, status.JobStatus)
}
}
if fileToken == "" {
result["status"] = "polling_timeout"
runtime.Out(result, nil)
return nil
}
outPath := strings.TrimSpace(runtime.Str("output-path"))
if outPath == "" {
runtime.Out(result, nil)
return nil
}
saved, err := downloadExportFile(ctx, runtime, fileToken, outPath, fileName)
if err != nil {
return err
}
result["saved_path"] = saved
runtime.Out(result, nil)
return nil
applyWorkbookOutputPath(&p, runtime.FileIO(), runtime.Str("output-path"))
return drive.RunExport(ctx, runtime, p)
},
Tips: []string{
"Polls up to ~30s (15 × 2s). For very large workbooks rerun and pass --output-path to capture the file once status flips to success.",
"Polls for a bounded window; if the export is still running it returns a resume reference instead of blocking. Pass --output-path to download the file once ready (omit it to only create the export task and get the file token back).",
},
}
type exportTaskStatus struct {
JobStatus int
JobErrorMsg string
FileToken string
FileName string
FileSize int64
FileExtension string
}
func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (exportTaskStatus, error) {
data, err := runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)),
map[string]interface{}{"token": token},
nil,
)
// workbookExportParams builds the shared drive export request for
// +workbook-export: spreadsheet token + sheet locator, pinned to type=sheet.
// workbook-export has always overwritten the target, so Overwrite is set. The
// --output-path → OutputDir/FileName split (which needs a Stat) is applied
// separately by applyWorkbookOutputPath so Validate/DryRun stay I/O-free.
func workbookExportParams(runtime *common.RuntimeContext) (drive.ExportParams, error) {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return exportTaskStatus{}, err
return drive.ExportParams{}, err
}
result := common.GetMap(data, "result")
if result == nil {
return exportTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "export task %s: empty result", ticket)
ext := runtime.Str("file-extension")
if ext == "" {
ext = "xlsx"
}
js, _ := util.ToFloat64(result["job_status"])
fs, _ := util.ToFloat64(result["file_size"])
return exportTaskStatus{
JobStatus: int(js),
JobErrorMsg: common.GetString(result, "job_error_msg"),
FileToken: common.GetString(result, "file_token"),
FileName: common.GetString(result, "file_name"),
FileSize: int64(fs),
FileExtension: common.GetString(result, "file_extension"),
return drive.ExportParams{
Token: token,
DocType: "sheet",
FileExtension: ext,
SubID: strings.TrimSpace(runtime.Str("sheet-id")),
Overwrite: true,
}, nil
}
func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outPath, preferredName string) (string, error) {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
}, larkcore.WithFileDownload())
if err != nil {
return "", output.ErrNetwork("download failed: %s", err)
// applyWorkbookOutputPath maps the single --output-path flag onto the drive
// export OutputDir/FileName pair, preserving the legacy behavior: empty = no
// download (return the ready file token only); an existing directory = download
// into it under the server-provided name; otherwise treat it as a file path and
// split into dir + base name.
func applyWorkbookOutputPath(p *drive.ExportParams, fio fileio.FileIO, outputPath string) {
outputPath = strings.TrimSpace(outputPath)
if outputPath == "" {
return
}
if apiResp.StatusCode >= 400 {
return "", output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody))
if info, err := fio.Stat(outputPath); err == nil && info.IsDir() {
p.OutputDir = outputPath
return
}
target := outPath
if info, statErr := runtime.FileIO().Stat(outPath); statErr == nil && info.IsDir() {
name := strings.TrimSpace(preferredName)
if name == "" {
name = client.ResolveFilename(apiResp)
}
target = filepath.Join(outPath, name)
}
if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{
ContentType: apiResp.Header.Get("Content-Type"),
ContentLength: int64(len(apiResp.RawBody)),
}, strings.NewReader(string(apiResp.RawBody))); err != nil {
return "", common.WrapSaveErrorByCategory(err, "io")
}
resolved, _ := runtime.FileIO().ResolvePath(target)
if resolved == "" {
resolved = target
}
return resolved, nil
p.OutputDir = filepath.Dir(outputPath)
p.FileName = filepath.Base(outputPath)
}
// lookupSheetIndex finds a sub-sheet by id or name and returns its canonical
@@ -1033,3 +986,45 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok
}
return bestID, nil
}
// ─── +workbook-import (reuses drive import core, cli_status: cli-only) ──
//
// Imports a local xlsx/xls/csv file as a brand-new spreadsheet. The full
// upload → create-task → poll flow is the shared drive import core
// (drive.RunImport); this shortcut only pins the target type to "sheet" and
// omits the bitable-only --target-token. Symmetric with +workbook-export.
// Not exposed as an MCP tool.
// WorkbookImport imports a local spreadsheet file as a new Feishu spreadsheet
// by delegating to the shared drive import core with type fixed to "sheet".
var WorkbookImport = common.Shortcut{
Service: "sheets",
Command: "+workbook-import",
Description: "Import a local xlsx/xls/csv file as a new spreadsheet (async + poll). Reuses the drive import core with type fixed to sheet.",
Risk: "write",
Scopes: []string{"docs:document.media:upload", "docs:document:import"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+workbook-import"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return drive.ValidateImport(workbookImportParams(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return drive.PlanImportDryRun(runtime, workbookImportParams(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return drive.RunImport(ctx, runtime, workbookImportParams(runtime))
},
}
// workbookImportParams builds the drive import request for +workbook-import,
// pinning DocType to "sheet". The bitable-only --target-token is intentionally
// not exposed here — use drive +import for non-sheet import targets.
func workbookImportParams(runtime *common.RuntimeContext) drive.ImportParams {
return drive.ImportParams{
File: runtime.Str("file"),
DocType: "sheet",
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
}
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
// TestWorkbookExport_ExecuteExportOnly covers the no-download path: without
// --output-path, +workbook-export delegates to the shared drive export core
// with OutputDir="" so it creates + polls the export task and returns the ready
// file token without writing a local file (downloaded=false).
func TestWorkbookExport_ExecuteExportOnly(t *testing.T) {
stubs := []*httpmock.Stub{
{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"ticket": "tk_export"},
},
},
{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_export",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"result": map[string]interface{}{
"job_status": float64(0),
"file_token": "ftk_xlsx",
"file_name": "report.xlsx",
"file_size": float64(2048),
}},
},
},
}
out, err := runShortcutWithStubs(t, WorkbookExport, []string{
"--url", testURL, "--file-extension", "xlsx", "--as", "user",
}, stubs...)
if err != nil {
t.Fatalf("export-only execute failed: %v\n%s", err, out)
}
idx := strings.Index(out, "{")
if idx < 0 {
t.Fatalf("no JSON envelope:\n%s", out)
}
var env struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
}
if env.Data["ready"] != true {
t.Errorf("ready = %v, want true", env.Data["ready"])
}
if env.Data["downloaded"] != false {
t.Errorf("downloaded = %v, want false (no --output-path)", env.Data["downloaded"])
}
if env.Data["file_token"] != "ftk_xlsx" {
t.Errorf("file_token = %v, want ftk_xlsx", env.Data["file_token"])
}
if env.Data["doc_type"] != "sheet" {
t.Errorf("doc_type = %v, want sheet", env.Data["doc_type"])
}
}

View File

@@ -0,0 +1,135 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
)
// chdirTemp switches into a fresh temp dir for the duration of the test and
// restores the original cwd afterwards. +workbook-import is the first sheets
// shortcut that stat()s a real local file, so these tests need a working dir.
func chdirTemp(t *testing.T) {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(orig) })
}
// TestWorkbookImport_DryRunPinsSheetType verifies the shortcut delegates to the
// shared drive import core and hard-codes the import target type to "sheet".
func TestWorkbookImport_DryRunPinsSheetType(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
calls := parseDryRunAPI(t, WorkbookImport, []string{"--file", "./data.xlsx"})
var createBody map[string]interface{}
for _, c := range calls {
cm, _ := c.(map[string]interface{})
if u, _ := cm["url"].(string); u == "/open-apis/drive/v1/import_tasks" {
createBody, _ = cm["body"].(map[string]interface{})
}
}
if createBody == nil {
t.Fatalf("no import_tasks create call in dry-run: %#v", calls)
}
if createBody["type"] != "sheet" {
t.Errorf("import type = %v, want sheet (must be pinned regardless of file)", createBody["type"])
}
if createBody["file_extension"] != "xlsx" {
t.Errorf("file_extension = %v, want xlsx", createBody["file_extension"])
}
}
// TestWorkbookImport_RejectsNonSheetFile ensures a file that cannot become a
// spreadsheet (e.g. .docx) is rejected up front by the pinned-sheet validation.
func TestWorkbookImport_RejectsNonSheetFile(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("notes.docx", []byte("fake-docx"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
// Validate runs before DryRun, so the pinned-sheet check rejects .docx up
// front and the error surfaces through the normal envelope/err path.
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookImport, []string{"--file", "./notes.docx", "--dry-run"})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "can only be imported") {
t.Errorf("expected .docx → sheet type-mismatch rejection; got stdout=%s stderr=%s err=%v", stdout, stderr, err)
}
}
// TestWorkbookImport_ExecuteCreatesSheet runs the full upload → create → poll
// flow against stubs and asserts the resulting URL is a /sheets/ link.
func TestWorkbookImport_ExecuteCreatesSheet(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
stubs := []*httpmock.Stub{
{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"file_token": "file_import_media"},
},
},
{
Method: "POST",
URL: "/open-apis/drive/v1/import_tasks",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"ticket": "tk_sheet"},
},
},
{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/tk_sheet",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"result": map[string]interface{}{
"token": "shtcn_imported",
"type": "sheet",
"job_status": float64(0),
}},
},
},
}
out, err := runShortcutWithStubs(t, WorkbookImport, []string{"--file", "./data.csv", "--as", "user"}, stubs...)
if err != nil {
t.Fatalf("import execute failed: %v\n%s", err, out)
}
idx := strings.Index(out, "{")
if idx < 0 {
t.Fatalf("execute output has no JSON envelope:\n%s", out)
}
var env struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
}
if url, _ := env.Data["url"].(string); !strings.Contains(url, "/sheets/") {
t.Errorf("imported url = %q, want a /sheets/ link", url)
}
if tok, _ := env.Data["token"].(string); tok != "shtcn_imported" {
t.Errorf("token = %q, want shtcn_imported", tok)
}
}

View File

@@ -140,6 +140,28 @@ func TestWorkbookShortcuts_DryRun(t *testing.T) {
"tab_color": "",
},
},
{
name: "+sheet-show-gridline",
sc: SheetShowGridline,
args: []string{"--url", testURL, "--sheet-id", testSheetID},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "show_gridline",
"sheet_id": testSheetID,
},
},
{
name: "+sheet-hide-gridline",
sc: SheetHideGridline,
args: []string{"--url", testURL, "--sheet-id", testSheetID},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "hide_gridline",
"sheet_id": testSheetID,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -339,21 +361,21 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
}
}
// TestWorkbookExport_DryRun checks the 2-or-3 step plan depending on
// --output-path. The order should be: POST → GET (poll) → optional GET
// (download).
// TestWorkbookExport_DryRun verifies the export dry-run now delegates to the
// shared drive export core: a single create-task POST (poll + download are
// described inline rather than as separate api entries).
func TestWorkbookExport_DryRun(t *testing.T) {
t.Parallel()
t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) {
t.Run("xlsx create-task body pins type=sheet", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"})
if len(calls) != 2 {
t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls))
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1 (create export task)", len(calls))
}
create := calls[0].(map[string]interface{})
if create["url"] != "/open-apis/drive/v1/export_tasks" {
t.Errorf("first url = %v", create["url"])
t.Errorf("url = %v", create["url"])
}
body, _ := create["body"].(map[string]interface{})
if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken {
@@ -361,22 +383,18 @@ func TestWorkbookExport_DryRun(t *testing.T) {
}
})
t.Run("csv → 3 steps, with sub_id", func(t *testing.T) {
t.Run("csv includes sub_id from --sheet-id", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookExport, []string{
"--url", testURL, "--file-extension", "csv", "--sheet-id", "sh1",
"--output-path", "/tmp/out.csv",
})
if len(calls) != 3 {
t.Fatalf("api calls = %d, want 3", len(calls))
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1", len(calls))
}
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
if body["sub_id"] != "sh1" {
t.Errorf("csv export missing sub_id: %#v", body)
}
dl := calls[2].(map[string]interface{})
if !strings.Contains(dl["url"].(string), "/export_tasks/file/") {
t.Errorf("download url = %v", dl["url"])
if body["type"] != "sheet" || body["sub_id"] != "sh1" {
t.Errorf("csv export body = %#v (want type=sheet, sub_id=sh1)", body)
}
})

View File

@@ -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, only writing
// plain values. Use +cells-set for anything richer (formula / style / note).
// 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.
var CsvPut = common.Shortcut{
Service: "sheets",
Command: "+csv-put",
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (plain values only, auto-expands sheet if needed).",
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).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},

View File

@@ -38,8 +38,11 @@ func shortcutList() []common.Shortcut {
SheetHide,
SheetUnhide,
SheetSetTabColor,
SheetShowGridline,
SheetHideGridline,
WorkbookCreate,
WorkbookExport,
WorkbookImport,
// lark_sheet_sheet_structure
SheetInfo,
@@ -56,6 +59,10 @@ func shortcutList() []common.Shortcut {
CellsGet,
CsvGet,
DropdownGet,
TableGet,
// lark_sheet_recover
Recover,
// lark_sheet_search_replace
CellsSearch,
@@ -67,6 +74,7 @@ func shortcutList() []common.Shortcut {
CellsSetImage,
CsvPut,
DropdownSet,
TablePut,
// lark_sheet_range_operations
CellsClear,

View File

@@ -1,7 +1,7 @@
---
name: lark-sheets
version: 2.0.0
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。仅针对飞书在线电子表格,不适用于本地 Excel 文件。"
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)、金融/财务建模DCF、三张表、预算、Sensitivity 等)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
metadata:
requires:
bins: ["lark-cli"]
@@ -40,18 +40,22 @@ metadata:
| --- | --- | --- |
| 读数据(纯值 / CSV | `+csv-get`(范围用 `--range` | — |
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `--with-styles``--with-merges``--include-merged-cells` |
| 写纯值(整块 CSV 平铺) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — |
| 写纯文本值(整块 CSV 平铺,列里没有需保留的数值 / 日期语义 | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — |
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期 / 计数,要可排序 / 求和 / 入图表 / 透视) | `+table-put`(列显式声明 `type` + `format`,类型保真;来源不限 DataFrame——Counter / dict / list 同理,详见 write-cells | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(会落成文本、丢失计算能力) |
| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`date / number 不丢,详见 workbook | 用 `--values` 灌日期 / 数字(会落成文本、丢类型) |
| 写值 / 公式 / 样式 | `+cells-set`(定位用 `--range` | — |
| 查找单元格 | `+cells-search`(关键字用 `--find` | `+cells-find``+find``--query` |
| 查找并替换 | `+cells-replace` | — |
| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `+sheet-get``+structure-get``+sheet-structure-get` |
| 看工作簿 / 子表清单 | `+workbook-info` | — |
| 导出 xlsx / 单表 csv | `+workbook-export` | — |
| 导入本地 xlsx/xls/csv 文件为新表格 | `+workbook-import --file ./x.xlsx`(仅导入为电子表格;要导成多维表格走 `drive +import --type bitable` | 把 .xlsx 在本地读成数据再 `+workbook-create` 重灌(丢原格式、低效) |
| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all | `--type` |
| 批量清除多区域 | `+cells-batch-clear``--scope` | `--target` |
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `--dimension`(无此 flag |
| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | 用 SUMIF / 本地脚本拼一张假透视表 |
> ⚠️ **纯文本还是数值语义**:要写的列里有数字 / 金额 / 百分比 / 日期 / 计数 → `+table-put`(写入已有表;声明 `type` + `format`,保留排序 / 求和 / 图表 / 透视能力;**目标表还不存在就用 `+workbook-create --sheets`**,同 typed 协议、一步建表 + 写入,别先建空表再 `+table-put`);只有纯文本才用 `+csv-put`。两者写完显示可以完全相同,但 `+csv-put` 落的是文本、不能参与计算——别把数值在本地拼成带 `$` / `%` 的字符串再走 `+csv-put`。
> ⚠️ **定位 flag**`+cells-get` / `+cells-set` / `+csv-get` 用 `--range``+csv-put` 规范用 `--start-cell`(单个左上角锚点格),也接受 `--range` 别名(区间自动取左上角),二者择一即可。
> ⚠️ **读取附加信息**一律走 `+cells-get --include …`**没有** `--with-styles` 这类 flag**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag。
@@ -63,28 +67,30 @@ metadata:
| Reference | 描述 |
| --- | --- |
| [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。不适用于本地 Excel 文件操作。 |
| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式高亮、标红、数据条、色阶请使用 lark-sheets-conditional-format。仅针对飞书表格,不适用于本地 Excel 文件。 |
| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开时使用。仅针对飞书在线表格,不适用于本地 Excel 文件执行。 |
| [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。 |
| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式高亮、标红、数据条、色阶请使用 lark-sheets-conditional-format。 |
| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开时使用。 |
| [飞书表格金融/财务建模规范](references/lark-sheets-financial-modeling-standards.md) | 飞书表格金融/财务/估值建模场景的专业结构与视觉规范DCF / LBO / Comps / Precedent、三张财务报表、预算与 Variance Analysis、Sensitivity / Scenario、FP&A 等。覆盖科目分类、三表勾稽、Assumptions / Calc / Sensitivity 拆分、年份横向布局、假设颜色编码、财务数字格式、Sensitivity 禁色阶等。与通用视觉规范冲突时以本文为准;用户明确样式或既有模板优先。 |
### 按对象的工具参考(含 shortcut
| Reference | 描述 |
| --- | --- |
| [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。仅针对飞书表格。 |
| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。仅针对飞书表格。 |
| [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。仅针对飞书表格。 |
| [Lark Sheet Search & Replace](references/lark-sheets-search-replace.md) | 在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要"查找"、"搜索"、"定位"某个值,或"替换"、"批量修改文本"、"把 A 改成 B"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如"汇总金额""统计数量")当作搜索词。仅针对飞书表格。 |
| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image若只需把一块 CSV 纯值批量铺到表格上(不带公式/样式),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。仅针对飞书表格。 |
| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark-sheets-write-cells。仅针对飞书表格。 |
| [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。仅针对飞书表格。 |
| [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。仅针对飞书表格。 |
| [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。仅针对飞书表格。 |
| [Lark Sheet Conditional Format](references/lark-sheets-conditional-format.md) | 管理飞书表格中的条件格式规则(重复值高亮、单元格值比较、数据条、色阶、排名、自定义公式等)。当用户需要创建条件格式、修改已有规则的范围或样式、查看当前条件格式配置、删除规则时使用。也适用于用户提到"高亮"、"标红"、"颜色标记"、"数据条"、"色阶"、"条件样式"等场景。仅针对飞书表格。 |
| [Lark Sheet Filter](references/lark-sheets-filter.md) | 管理飞书表格中的筛选器filter。当用户需要筛选数据按文本/数值/颜色/日期条件过滤行)、查看已有筛选配置、修改或删除筛选器时使用。也适用于"只看"、"筛选出"、"仅保留符合条件的"等场景。仅针对飞书表格。 |
| [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图filter view。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器filter相互独立可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。仅针对飞书表格。 |
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。仅针对飞书表格。 |
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。仅针对飞书表格。 |
| [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。 |
| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。 |
| [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。 |
| [Lark Sheet Search & Replace](references/lark-sheets-search-replace.md) | 在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要"查找"、"搜索"、"定位"某个值,或"替换"、"批量修改文本"、"把 A 改成 B"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如"汇总金额""统计数量")当作搜索词。 |
| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image若只需把一块 CSV 批量铺到表格上(值或公式,不带样式/批注),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。 |
| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark-sheets-write-cells。 |
| [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。 |
| [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。 |
| [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。 |
| [Lark Sheet Conditional Format](references/lark-sheets-conditional-format.md) | 管理飞书表格中的条件格式规则(重复值高亮、单元格值比较、数据条、色阶、排名、自定义公式等)。当用户需要创建条件格式、修改已有规则的范围或样式、查看当前条件格式配置、删除规则时使用。也适用于用户提到"高亮"、"标红"、"颜色标记"、"数据条"、"色阶"、"条件样式"等场景。 |
| [Lark Sheet Filter](references/lark-sheets-filter.md) | 管理飞书表格中的筛选器filter。当用户需要筛选数据按文本/数值/颜色/日期条件过滤行)、查看已有筛选配置、修改或删除筛选器时使用。也适用于"只看"、"筛选出"、"仅保留符合条件的"等场景。 |
| [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图filter view。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器filter相互独立可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。 |
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。 |
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。 |
| [Lark Sheet Recover](references/lark-sheets-recover.md) | 整表回退到历史版本(方案 B。当用户提到"恢复到之前的版本"、"撤销所有改动回到某个状态"、"整表回滚"、"recover"时使用。这是全文档回退(丢弃目标版本之后的所有编辑,含他人改动)——要精确逐笔撤销本链路最近编辑,用 +undo。 |
## 公共 flag 速查
@@ -102,7 +108,7 @@ metadata:
1. **spreadsheet 定位(必填)**`--url``--spreadsheet-token` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突。
- **`--url` 只解析 `/sheets/``/spreadsheets/` 两种链接**(从路径里抽出 token也可以直接把裸 token 传给 `--spreadsheet-token`)。其它形态的链接不会被解析成表格 token。
- ⚠️ **`/wiki/` 知识库链接不能直接当表格定位用**wiki 链接背后可能是电子表格,也可能是文档 / 多维表格等其它类型,`--url` **不会**自动把 wiki token 解析成 spreadsheet token直接传会失败。必须先把它解析成真实文档 token —— `lark-cli wiki +node-get --node-token "<wiki 链接或 token>"`,确认返回的 `obj_type``sheet` 后,取其 `obj_token` 作为 `--spreadsheet-token` 传入(解析细节见 [`../lark-wiki/SKILL.md`](../lark-wiki/SKILL.md))。
- **例外**`+workbook-create` 是新建一个还不存在的表格,**不接受任何 spreadsheet / sheet 定位 flag**只有 `--title` / `--folder-token` / `--headers` / `--values`
- **例外**`+workbook-create`(新建表 + 可选写入数据)与 `+workbook-import`(把本地文件导入为新表)都产出一张**还不存在**的表格,**不接受任何 spreadsheet / sheet 定位 flag**——`+workbook-create` 只有 `--title` / `--folder-token` / `--values` / `--styles` / `--sheets``+workbook-import` 只有 `--file`(必填)/ `--folder-token` / `--name`
2. **sheet 定位(公共四件套 shortcut 必填)**`--sheet-id``--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`
- ⚠️ **不确定 sheet 名时禁止直接猜 `Sheet1`**:除非用户对话明确说出 sheet 名 / id或上下文之前的工具调用 / URL 锚点 `?sheet=xxx`)已经出现过具体值,否则**第一步先调 `+workbook-info --url "..."`**(或 `--spreadsheet-token`)拿 `sheets[].sheet_id` / `sheets[].title` 列表再选。中文环境下子表常叫"数据" / "Sheet"(无数字)/ "工作表 1" / 业务名,猜 `Sheet1` 大概率撞 `sheet not found`,比先查多耗一次失败调用 + 重试。
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range 'Sheet1!A1:B2'`,仍**必须**额外传 `--sheet-id``--sheet-name`,否则照样报上面的错。

View File

@@ -36,7 +36,8 @@
- **默认情况inline 模式)**`refs` 范围**应包含表头行**(首行/首列即系列名),且范围要精确覆盖目标数据,不要多选或少选。
- **合并标题行要跳过**:如果表格在表头上方存在合并的标题行(如"员工统计表"横跨多列的大标题),`refs` 必须跳过标题行、从真正的列标题行开始。例如表头在第 3 行、数据在第 4-20 行,则 `refs` 应为 `A3:G20` 而非 `A1:G20`。包含合并标题行会导致列名识别错误、表头被当作数据参与聚合计算。
- **数据与表头分离时必须用 detached 模式**:当 `refs` 只覆盖完整数据的一个子集(按筛选/分组只画其中一段),而真正的语义表头在该子集之外时,**必须**设置 `snapshot.data.headerMode='detached'`refs 仅传纯数据范围,维度名/系列名通过 `snapshot.data.dim1.serie.nameRef` / `snapshot.data.dim2.series[].nameRef` 指向真正的表头单元格。详见下文"硬性规则:数据与表头分离场景必须使用 detached 模式"。
- **axes[].label 不接受 `format` / `number_format` 字段**:想给坐标轴数值加千分位、百分号等格式化时,不要在 `axes[i].label` 里传 `format``number_format`schema 未定义,会报 `unexpected property "format" is not defined in schema`)。数值格式化统一在源数据单元格的 `cell_styles.number_format` 里设置(写 `+cells-set` 时),图表会沿用单元格格式。
- **axes[].label 不接受 `format` / `number_format` 字段**:想给坐标轴数值加千分位、百分号等格式化时,不要在 `axes[i].label` 里传 `format``number_format`schema 未定义,会报 `unexpected property "format" is not defined in schema`)。数值格式化统一在源数据单元格的 `cell_styles.number_format` 里设置(写 `+cells-set` 时),图表会沿用单元格格式。**日期轴同理**:横轴显示成 `45297` 这类 Excel 序列号,是因为源日期列没设日期格式——给源列设 `number_format="yyyy-mm-dd"` 后横轴才会显示成日期(反例:折线图横轴日期显示为序列号)。大数值轴显示科学计数法同理,给源列设整数 / 千分位格式(反例:透视表数值轴显示科学计数法)。
- **轴口径要对齐用户要的指标**:用户要"占比 / 比例"时,**纵轴应是百分比**——用饼图,或柱 / 条形图设 `stack.percentage: true` 让纵轴变 %,并把数据源指向占比列 / 让数据标签显示百分比;不要交付纵轴仍是原始计数的图(反例:要求看各类占比,却用普通堆积柱、纵轴是 0350 的人数而非百分比)。
- **创建后必须验证**:图表创建后必须调用 `+chart-list` 验证配置是否正确
> **⚠️ 硬性规则:当用户通过列标题名称(而非列索引)指定横轴/纵轴系列时,必须先读取表格首行(表头)来确定列名与列索引的对应关系,再设置 `dim1`/`dim2` 的 `index`。**
@@ -84,15 +85,17 @@
1. **查尺寸**`+workbook-info` 拿该 sheet 的 `row_count` / `column_count`(下文记为 rowCount / columnCount`+sheet-info` 只返回布局,不含行列总数)。
2. **估跨度**:默认单元格 **105 px 宽 × 27 px 高**`needCols = ceil(width/105)``needRows = ceil(height/27)`
3. **校验**`position.row + needRows ≤ rowCount``col_idx + needCols ≤ columnCount`col 按 A=0、B=1、…、Z=25、AA=26… 换算)。
3. **校验**`position.row + needRows ≤ rowCount``col_idx + needCols ≤ columnCount``position.row`**0-based**:首行 = `row:0`,与 A1 区间 / `+dim-insert --position` 的 1-based 行号不同;col 按 A=0、B=1、…、Z=25、AA=26… 换算)。
4. **不够就先扩表**,二选一,禁止硬塞越界位置:
- **优先**放数据下方空区:`position = {row: data_end_row + 2, col: "A"}`
- 否则先调 `+dim-insert``lark-sheets-sheet-structure`)扩行/列,再 create。
⚠️ **图表落点禁止压在已有数据矩形内**——必须落在数据区**右侧或下方的空白**,否则图表浮层会遮挡原始数据被判失败(反例:折线图落在数据区中间,遮挡了下方原始数据)。
**示例**21 列 sheet 放 600×400 图 → `needCols=6, needRows=15`
-`{row: 0, col: "W"}` — col=22 越界
-`{row: 42, col: "A"}` — 放数据下方
- ✅ 先 `+dim-insert --dimension column --start 21 --end 27`(在 U插 6 列U=index 20after 即从 21 起),再放图到 `{row: 0, col: "V"}`
- ✅ 先 `+dim-insert --position V --count 6`(在 V插 6 列,即 U 列之后),再放图到 `{row: 0, col: "V"}`
## Shortcuts
@@ -147,9 +150,9 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_
_创建/更新的图表属性_
**顶层字段**
- `position` (object) — 必填 { row: number, col: string }
- `position` (object?) — 必填 { row: number, col: string }
- `offset` (object?) — 可选 { row_offset?: number, col_offset?: number }
- `size` (object) — 必填 { width: number, height: number }
- `size` (object?) — 必填 { width: number, height: number }
- `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea: object, …共 6 项 }
## Examples
@@ -164,24 +167,28 @@ _创建/更新的图表属性_
> **`snapshot.data` 必填 `dim1.serie.index` 或 `dim2.series[].index` 之一**1-based对应 `refs.value` 范围内的列序。schema 允许传空 `{}` 但 server 运行时强制:缺则被拒为 `snapshot.data.dim1.serie.index and dim2.series[].index are both missing; at least one must be set`,即便侥幸通过也只会渲染空图。
> ⚠️ **含 `'Sheet'!` 前缀的 `--properties` 必须走 stdin 或 `@file`,不要用 inline 单引号**。`refs` / `nameRef` 里的 sheet 前缀带单引号(`'Sheet1'!A1`),若塞进 inline 的 `--properties '{...}'`bash 会把内层那对单引号吃掉sheet 名带空格还会被拆成多个词JSON 直接被破坏。下面示例统一用 `--properties - <<'JSON' … JSON`heredoc 定界符加引号 = 不做 shell 替换),或 `--properties @file.json``@` 只接 cwd 下相对路径)。
最小可用列图inline 模式refs 含表头行):
```bash
lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \
--sheet-name "Sheet1" --properties '{
"position":{"row":42,"col":"A"},
"size":{"width":600,"height":400},
"snapshot":{
"data":{
"refs":[{"value":"'Sheet1'!A1:B10"}],
"dim1":{"serie":{"index":1}},
"dim2":{"series":[{"index":2}]}
},
"plotArea":{"plot":{"type":"column"}}
}
}'
--sheet-name "Sheet1" --properties - <<'JSON'
{
"position":{"row":42,"col":"A"},
"size":{"width":600,"height":400},
"snapshot":{
"data":{
"refs":[{"value":"'Sheet1'!A1:B10"}],
"dim1":{"serie":{"index":1}},
"dim2":{"series":[{"index":2}]}
},
"plotArea":{"plot":{"type":"column"}}
}
}
JSON
# 走文件(推荐配置较多时)
# 或落到 cwd 下相对路径文件再用 @file
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties @chart-config.json
```
@@ -190,7 +197,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties @ch
饼图比 column / bar 更复杂:`sectors` 是 object里面再包一个**单数** `sector` 数组——CLI 不替你 normalize写错路径会被 server schema 直接拒。
```bash
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties - <<'JSON'
{
"position":{"row":24,"col":"F"},
"size":{"width":600,"height":450},
"snapshot":{
@@ -208,7 +216,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{
"dim2":{"series":[{"index":2,"aggregateType":"sum"}]}
}
}
}'
}
JSON
```
**数据与表头分离(必须用 `detached` + `nameRef`**
@@ -216,7 +225,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{
场景:周度销量明细表,真实表头在第 1 行A1=周次、C1=订单量、D1=退款量),数据按 B 列"店铺"分段;用户只要"3 号店"那一段(第 1117 行)。
```bash
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties '{
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties - <<'JSON'
{
"position":{"row":7,"col":"F"},
"size":{"width":600,"height":360},
"snapshot":{
@@ -233,7 +243,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties '{
]}
}
}
}'
}
JSON
```
约束:

View File

@@ -4,29 +4,30 @@
面向"已有飞书表格"的核心工作流,核心原则:**先了解,再分析或写入,最后验证**。本文是方法论总纲;具体工具的参数细节、边界陷阱在对应子 skill本文用指针引到那里不重复展开。
**份「通用方法与规范」如何分工**(都不含 shortcut按主题单一归属
**份「通用方法与规范」如何分工**(都不含 shortcut按主题单一归属
- **本文core-operations= 流程与铁律**:端到端工作流 + 全局铁律 + 横切陷阱,是读取入口与枢纽。
- **`lark-sheets-visual-standards` = 样式知识**:配色 / 表头 / 数值格式 / 斑马纹 / 美化决策等"正确视觉输出"的全部标准。
- **`lark-sheets-formula-translation` = 公式知识**:飞书公式书写与 Excel 迁移的全部正确性规则(绝对引用、范围语法、数组语义、不支持函数等)。
- **`lark-sheets-financial-modeling-standards` = 金融/财务建模知识**DCF / 三张表 / 预算 / Sensitivity 等专业模型的结构、公式、颜色编码、数字格式与验收规则;与通用视觉规范冲突时以金融规范为准。
> **下面的铁律对所有任务一律生效**,即使你是被索引直接路由进 visual 或 formula 而没经过本文——编辑类任务务必先回到这里过一遍铁律。
## 铁律(所有编辑类任务必须满足,子 skill 不得放宽)
1. **最小改动**:除用户明示要改的单元格 / 列外原表其它单元格、行列结构、Sheet 名、合并区、格式必须 1:1 保持。中间结果优先放原数据**右侧**;会与原数据混淆或要承载透视表 / 图表时才**新建空白 Sheet**。**禁止**擅自删 / 改名 / 隐藏 / 移动**已存在**的 Sheet新建允许节制使用
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,并 `+csv-get` / `+cells-get` / `+<对象>-list` 回读校验。**严禁**只在文本里描述"已完成"、用普通公式 / 文本假装结构化对象、或只给占位而无真实写入。
1. **最小改动**:除用户明示要改的单元格 / 列外原表其它单元格、行列结构、Sheet 名、合并区、格式必须 1:1 保持。中间结果优先放原数据**右侧**;会与原数据混淆或要承载透视表 / 图表时才**新建空白 Sheet**。**禁止**擅自删 / 改名 / 隐藏 / 移动**已存在**的 Sheet新建允许节制使用**改写 / 转换类任务要精确圈定适用行列**:只对任务真正要求的对象做变换,**不该转的行 / 列保持原值 1:1**(典型反例:要求"统一翻译"时把本就是中文、应原样保留的评论也重新翻译;要求"改写某列格式"时连原始测量值也一并改动 → 应保留的原文被篡改)。
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,并 `+csv-get` / `+cells-get` / `+<对象>-list` 回读校验。**严禁**只在文本里描述"已完成"、用普通公式 / 文本假装结构化对象、或只给占位而无真实写入。**收尾前必须确认产物文件真实存在 / 可导出**——别在没真正生成产物时只凭文本"已完成"就结束(反例:文本称已完成,实际没生成产物文件,等于没交付)。
3. **读全再写,禁止只探前 N 行**:批量填充 / 补齐 / 修正类任务必须先确认**真实数据末行**再写,否则会漏写表尾(高频致命错误)。完整的"按表格形态分流读取 + `current_region` / `has_more` 兜底 + 真实末行确认"流程见 `lark-sheets-read-data` 的「确定数据范围的正确流程」。
4. **公式优先于硬编码**:能用飞书公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找等)一律写公式而非静态值,源数据变化才能自动重算。用户口头的"分列 / 排序 / 求和 / 提取"也要落地为公式或原生工具SORT / `TEXTBEFORE` / `MID` / 透视表 等。Excel 公式迁移、数组语义、不支持函数清单一律以 `lark-sheets-formula-translation` 为唯一权威。
4. **公式优先于硬编码**:能用飞书公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找等)一律写公式而非静态值,源数据变化才能自动重算。用户口头的"分列 / 排序 / 求和 / 提取"也要落地为公式或原生工具SORT / `TEXTBEFORE` / `MID` / 透视表 等。Excel 公式迁移、数组语义、不支持函数清单一律以 `lark-sheets-formula-translation` 为唯一权威。**即使用户没说"联动 / 自动更新",凡是可由表内其它单元格推导的派生值(年龄=当年-出生年、占比=本类数/总数、达标=阈值判断、排名、各类分组汇总)默认就必须用公式**——用户默认期望派生列能随源数据重算,**离线 Python / 脚本算完写静态值,即便当前数值正确,改了源数据也不会自动更新,等于没满足"派生"的本意**(反例:年龄、月度汇总、占比、分组求和等派生列写死值,源数据一改结果就过时)。
5. **续写 / 扩展必须继承样式**:续写、补齐、复制区块、新增行列时,**禁止**只读值只写值。必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承。完整继承清单与做法见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」(`border_styles` 四边最易漏)。
6. **多步写入优先 `+batch-update`**:多个连续写入、或同一工具对多个区域重复调用(多次 merge / resize / cells-set必须合并为单次原子 `+batch-update`。语义与不可嵌套的限制见 `lark-sheets-batch-update`
7. **分组汇总必须用透视表**"按 X 统计 Y / 分组汇总 / 各部门数量金额"必须用 `+pivot-{create|update|delete}`(推荐省略 sheet_id 自动新建子表),**禁止**用 SUMIF / COUNTIF 或本地脚本覆盖原表替代。
8. **任务拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",每点一个 `assert`,全部通过才交付:多维度操作(按部门一/二/三级排序)每维一个 assert多目标删 N 行每目标一个多格式兼容多种日期格式每种至少一个样本范围类A1:H11 加边框)起 / 末行 / 末列三边界都核。只完成第一个要点(只排一级、只删 1 行)属违规。
8. **任务拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",每点一个 `assert`,全部通过才交付:多维度操作(按部门一/二/三级排序)每维一个 assert多目标删 N 行每目标一个多格式兼容多种日期格式每种至少一个样本范围类A1:H11 加边框)起 / 末行 / 末列三边界都核。只完成第一个要点(只排一级、只删 1 行)属违规。**题面 / 表头里写明的格式规范也是子要点**:表头注明"需标注某字段"就必须给对应单元格加规定前缀并逐条 assert 前缀存在(反例:漏加规定前缀,该要点即不达标);"相同编号连续行合并"必须遍历所有相同编号组全部合并(反例:只合并了其中一部分组)。
9. **全量处理要前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,落地前把"预期处理条数"硬编码进代码,处理完 `assert actual == expected`。**严禁**输出"已完成前 N 条,剩余将继续"的半成品。
## 推荐工作流程
1. **规划 skill 清单**:开工前一次性列出本任务要读的子 skill避免读一个调一个本轮已读过的不重复读。本 skill + `lark-sheets-workbook` 几乎每次都要。
1. **规划 skill 清单**:开工前一次性列出本任务要读的子 skill避免读一个调一个本轮已读过的不重复读。本 skill + `lark-sheets-workbook` 几乎每次都要;任务涉及 DCF、三张表、预算、Variance、Sensitivity、估值、FP&A 等金融/财务建模时,必须额外读取 `lark-sheets-financial-modeling-standards`
2. **了解结构**:先 `+workbook-info` 拿子表列表 / 行列数 / 冻结位置(不可猜测,猜错会越界覆盖);涉及合并 / 隐藏 / 分组 / 行高列宽再用 `lark-sheets-sheet-structure``+sheet-info`
3. **读取数据(按任务类型选路径,细则见 `lark-sheets-read-data`**
@@ -67,6 +68,7 @@
- **喂给 CLI 的 CSV / JSON 用 UTF-8、不带 BOM**BOM 会污染首格的值或触发 `invalid character` 解析错;脚本读写文件时显式指定 `encoding='utf-8'`
- **临时文件交给运行时的标准库**:用 `tempfile.gettempdir()` / `os.tmpdir()` 等取临时目录,不要硬编码固定路径;放在用户项目目录之外。
- **命令失败先读错误再调整**:同一条命令失败后不要原样重发;先看 stderr 的报错(参数错误、缺依赖、解释器不可用等)定位原因,再决定换写法、补依赖或退回原生工具。
- **写回的必须是纯单元格值,禁止把"值+样式标注"串当值写回**:本地脚本或某些 xlsx 解析库会把单元格渲染成 `甲方支行(V-Align: bottom)` 这种"值(样式)"字符串CSV 字段还可能带包裹双引号。回写前必须**剥离括号样式标注、去掉残留引号**,只写原始值——否则样式描述会变成单元格的字面文本污染原数据(反例:排序后单元格值里被写进 `(V-Align: bottom)` 这类样式后缀文本,末尾还多一个双引号)。**排序本身优先用 `+range-sort` 原生工具**,不要"读出来本地排完再整列写回",从根上避免这类回写污染。
## 公式策略

View File

@@ -109,6 +109,17 @@ lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \
--properties '{"rules":[{"column_index":"C","conditions":[{"type":"number","compare_type":"greaterThan","values":[100]}]}]}'
```
**`conditions[].type` × `compare_type` 取值**`type` 决定可用的 `compare_type`;两者均必填):
| `type` | 可用 `compare_type` | `values` |
|---|---|---|
| `text` | `contains` / `doesNotContain` / `beginsWith` / `doesNotBeginWith` / `endsWith` / `doesNotEndWith` / `equals` / `notEquals` | 字符串数组 |
| `number` | `equal` / `notEqual` / `greaterThan` / `greaterThanOrEqual` / `lessThan` / `lessThanOrEqual` / `between` / `notBetween` | 数值(或数值字符串)数组;`between` / `notBetween` 传两个边界 |
| `multiValue` | `equal` / `notEqual` | 字符串数组(精确匹配其中任一值) |
| `color` | `backgroundColor` / `foregroundColor` | 不传 `values`(按单元格颜色筛选) |
> ⚠️ `text` 用 `equals` / `notEquals`**带 s**`number` / `multiValue` 用 `equal` / `notEqual`**不带 s**)——别混。完整 schema 跑 `+filter-view-create --print-schema --flag-name properties`。
> `--range` **必须覆盖表头行**(如 `A1:F1000`),不能只包含数据行;`--view-name` 重名时服务端自动改名。
### `+filter-view-update`

View File

@@ -102,6 +102,17 @@ lark-cli sheets +filter-create --url "..." --sheet-id "$SID" \
--properties '{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["北京","上海"]}]}]}'
```
**`conditions[].type` × `compare_type` 取值**`type` 决定可用的 `compare_type`;两者均必填):
| `type` | 可用 `compare_type` | `values` |
|---|---|---|
| `text` | `contains` / `doesNotContain` / `beginsWith` / `doesNotBeginWith` / `endsWith` / `doesNotEndWith` / `equals` / `notEquals` | 字符串数组 |
| `number` | `equal` / `notEqual` / `greaterThan` / `greaterThanOrEqual` / `lessThan` / `lessThanOrEqual` / `between` / `notBetween` | 数值(或数值字符串)数组;`between` / `notBetween` 传两个边界 |
| `multiValue` | `equal` / `notEqual` | 字符串数组(精确匹配其中任一值) |
| `color` | `backgroundColor` / `foregroundColor` | 不传 `values`(按单元格颜色筛选) |
> ⚠️ `text` 用 `equals` / `notEquals`**带 s**`number` / `multiValue` 用 `equal` / `notEqual`**不带 s**)——别混。完整 schema 跑 `+filter-create --print-schema --flag-name properties`。
### `+filter-update`
> ⚠️ update 是覆盖式:`--properties` 中传新 `rules` 会替换旧组。如只想加一条,要带上已有的全部条件再追加。必填 `--range`。

View File

@@ -0,0 +1,332 @@
# 飞书表格金融/财务建模规范
## 定位与优先级
本文用于飞书在线表格中的金融、财务、估值和经营分析场景,包括 DCF / LBO / Comps / Precedent、三张财务报表、预算与 Variance Analysis、Sensitivity / Scenario Analysis、资本结构、Unit Economics、Market Sizing、FP&A 和投研/投行/PE/咨询模型。
优先级从高到低:
1. 用户明确指令或既有模板样式。
2. 本金融/财务建模规范。
3. 通用核心操作、视觉规范与公式规则。
如果本文与通用视觉规范冲突,以本文为准。典型冲突:
| 通用规范 | 财务模型规范 |
| --- | --- |
| 数据行较多时可使用斑马纹 | 财务模型默认禁止斑马纹,避免干扰小计/合计识别 |
| 可用数据条、色阶强化可视化 | Sensitivity 禁止色阶、数据条和图标集 |
| 列宽按内容自适应 | 年份列必须紧凑等宽;标签列才加宽 |
| 可用柔和竖线分隔区域 | 普通财务模型禁止竖线Sensitivity 矩阵浅灰细框是唯一例外 |
## 按任务裁剪
不要把全套 DCF 规范套到简单费用表。先判断任务类型,再选择规则。
| 任务类型 | 必用规则 | 不要做 |
| --- | --- | --- |
| 三表整理 / 标准化 | 科目分类、三表结构、勾稽验证、颜色编码、数字格式、单位、年份横向布局、小计/合计边框 | 不需要 Sensitivity |
| DCF / LBO / 估值模型 | 假设/计算/输出分层,多 sheet 拆分横向年份假设单独落地公式引用假设Sensitivity 按需独立 | 不要把假设硬编码进公式 |
| Comps / Precedent | 科目口径、颜色编码、数字格式、单位、分区和小计样式 | 通常不需要三表勾稽,不强制拆多 sheet |
| 预算 / Variance Analysis | 颜色编码、数字格式、横向期间、差异列公式、汇总边框 | 不需要 Terminal Value / WACC |
| 简单 FP&A 汇总 / 费用表 | 基础颜色编码、数字格式、单位、合理列宽 | 不强行拆 sheet不强行做三层边框 |
| 单项 Sensitivity / Scenario | 假设分离、Sensitivity 专用视觉规范、baseline 标注 | 不使用色阶/数据条 |
## Pandas / DataFrame 落地路径
金融和财务任务经常先用 Python / pandas 做清洗、分组、透视、回归、敏感性计算或估值汇总,再把 DataFrame 写回飞书表格。只要结果列里有金额、百分比、日期、计数、倍数等数值语义,默认走 typed 表格协议,不要先把 DataFrame 格式化成 CSV 字符串。
按目标选择写入命令:
| 目标 | 命令 | 用法 |
| --- | --- | --- |
| 写入已有 spreadsheet | `+table-put --sheets` | 把 DataFrame 转成 `{sheets:[...]}`,按 sheet 名匹配,缺 sheet 时创建,支持覆盖 / 追加 |
| 新建 spreadsheet 并写入结果 | `+workbook-create --sheets` | 协议与 `+table-put` 同构,一步建表 + typed 写入,适合 pandas 算完直接交付新模型 |
typed payload 结构:
```json
{
"sheets": [
{
"name": "Output",
"start_cell": "A1",
"mode": "overwrite",
"columns": [
{"name": "Date", "type": "date", "format": "yyyy-mm-dd"},
{"name": "Revenue", "type": "number", "format": "$#,##0;($#,##0);\"-\""},
{"name": "EBITDA Margin", "type": "number", "format": "0.0%"}
],
"rows": [
["2026-12-31", 708000000, 0.29]
]
}
]
}
```
DataFrame 转 payload 时按业务语义定列类型:
- 金额、收入、费用、利润、人数、股数、倍数、百分比都用 `type:"number"`;百分比存小数,如 `12.5%``0.125`,靠 `format:"0.0%"` 显示。
- 日期列用 `type:"date"`,值用 ISO 日期字符串;不要把日期预格式化成普通文本。
- 订单号、股票代码、员工编号等需要保留前导零或不参与计算的字段用 `type:"string"`
- pandas 计算出的源数据 / 输出表先用 `+table-put``+workbook-create --sheets` 落地公式、颜色编码、边框、Sensitivity baseline 高亮再用 `+cells-set` / `+cells-set-style` 补。
## 财务逻辑规范
### 科目标准分类
整理原始科目时,不能把 raw data 机械拼接成报表。必须按 GAAP / IFRS 和财务建模惯例分类。
Income Statement 关键分类:
| 分类 | 示例 | 高频错误 |
| --- | --- | --- |
| Revenue | Product Revenue、Service Revenue、Subscription、License | 把 Other Income 混入主营收入 |
| COGS | 直接人工、直接材料、制造费用、SaaS hosting/infrastructure、支付处理费 | 把 Implementation SG&A / Customer Success 误归 COGS |
| SG&A | G&A、Sales & Marketing、Implementation SG&A、Customer Success、Operations SG&A、Shared Service | 把 Marketing 误归 COGS漏掉分摊费用 |
| R&D | Research、Product Development、Engineering Payroll | 并入 G&A |
| D&A | Depreciation、Amortization | 埋在 COGS 或 SG&A 里 |
| Non-operating | Interest、FX、One-time Items | 和 Operating Income 混淆 |
| Tax | Income Tax Expense / Benefit | 当作经营费用 |
Balance Sheet 按 Current / Non-current 拆分 Assets、Liabilities、EquityCash、AR、Inventory、Prepaids 属 Current AssetsPP&E / Goodwill / Intangibles 属 Non-current AssetsAP / Accrued / ST Debt / Deferred Revenue 属 Current LiabilitiesLT Debt / Deferred Tax 属 Non-current Liabilities。
Cash Flow Statement 使用间接法Net Income 起步,加回 D&A / SBC调整 Working Capital 和 Deferred Tax分 CFO / CFI / CFF最终 Ending Cash 必须等于 BS Cash。
### 标准报表结构
Income Statement 自上而下:
```text
Revenue
Total Revenue
- COGS
Total COGS
= Gross Profit
Gross Margin %
- Operating Expenses
Total OpEx
= EBITDA
EBITDA Margin %
- D&A
= EBIT
- Interest / Other Income (Expense)
= Pre-tax Income
- Tax
= Net Income
Net Income Margin % / EPS
```
Balance Sheet 必须有 `Total Assets``Total Liabilities``Total Equity``Total Liabilities & Equity``Check: Total Assets - Total L&E = 0`
Cash Flow Statement 必须有 CFO、CFI、CFF、Net Change in Cash、Beginning Cash、Ending Cash并校验 Ending Cash = BS Cash。
DCF 标准骨架:
```text
1. Key Assumptions
2. Revenue / EBITDA / EBIT / NOPAT
3. + D&A - CapEx - Change in NWC = UFCF
4. Discount Factor / PV of UFCF
5. Terminal Value
6. Enterprise Value -> Equity Value -> Implied Share Price
7. Sensitivity / Scenario
```
### 多 sheet 拆分
专业模型按 Input -> Calc -> Output 分层,复杂模型必须拆分:
- DCF / LBO`Assumptions``DCF - Calc``Sensitivity``Output / Summary`,可选 `Source Data`
- 三表模型:`Assumptions``IS``BS``CFS``Supporting Schedules``Check`
- 带 Sensitivity 的任何模型Sensitivity 必须独立 sheet 或独立清晰区块。
简单报表整理、费用汇总、Variance Analysis、单页 Comps/Precedent、总行数小于 40 的单一逻辑块可以不拆 sheet。
跨 sheet 引用规则:
1. 引用路径写完整 sheet 名,如 `='Assumptions'!$B$7`sheet 名含空格或特殊字符时按飞书公式规则加引号。
2. 数据流单向:`Assumptions -> Calc -> Sensitivity -> Output`
3. 禁止循环引用Sensitivity 直接引用 Calc 结果,不链式穿透多个中间 sheet。
### 年份横向布局
时间必须横向排布,科目纵向排布。所有相关 sheet 的年份列必须对齐。
正确示例:
```text
FY2023A FY2024A FY2025E FY2026E
Revenue 500 575 644 708
EBITDA 125 155 180 205
```
假设值如果按年度变化,也必须横向排布并与计算 sheet 的年份列一一对齐:
```text
FY2025E FY2026E FY2027E
Revenue Growth % 12.0% 10.0% 9.0%
EBITDA Margin % 25.0% 26.0% 27.0%
CapEx % of Revenue 5.0% 5.0% 4.5%
Tax Rate 25.0% 25.0% 25.0%
```
永续性假设如 WACC、Terminal Growth 可以单独放在 Assumptions 上方,不按年份展开。
### 假设值与公式
可被用户修改的假设必须集中放在 Assumptions 区或 sheet用蓝色字体标识并由公式引用。禁止把 Growth、Margin、WACC、Tax Rate、CapEx %、Terminal Growth、倍数等硬编码在公式里。
只有三类单元格可直接写静态值:
1. 历史真实数据。
2. 蓝色输入假设。
3. 外部来源的静态取数,且必须标注来源。
横向拉公式时必须正确使用 `$`
| 被引用内容 | 正确模式 | 说明 |
| --- | --- | --- |
| 同行逐年变化值 | `A1` | 向右复制时跟随年份变动 |
| 单一永续假设 | `$B$7` | 向右/向下都锁定 |
| 同列假设、逐行变化 | `$B7` | 锁列不锁行 |
| 年份标题行 | `B$4` | 锁行不锁列 |
写完横向公式后,必须回读 2-3 个相邻年份列的公式字符串和值,确认年份引用跟随列移动、被锁定的假设保持不变,且没有 `#REF!``#DIV/0!``#VALUE!``#NAME?``#N/A`
## 视觉规范
### 字体颜色编码
财务模型用字体颜色表达单元格性质。
| 颜色 | Hex | 含义 |
| --- | --- | --- |
| 蓝色字体 | `#0000FF` | 硬编码输入值、用户可修改假设 |
| 黑色字体 | `#000000` | 本 sheet 公式或普通文本 |
| 绿色字体 | `#008000` | 同工作簿跨 sheet 引用;公式中只要出现跨 sheet 引用就用绿色 |
| 红色字体 | `#FF0000` | 外部文件/外部系统链接,慎用 |
| 灰色斜体 | `#808080` + italic | YoY、Margin、Notes、单位说明、数据来源 |
| 黄色背景 | `#FFFF00` | 待确认或待复核假设 |
辅助行字号与正文一致,只靠灰色和斜体区分层级。除 sheet 顶部大标题外,全表正文、分区标题、副标题、辅助行使用统一字号。
### 数字格式
| 数据类型 | 推荐格式 |
| --- | --- |
| 年份 | `0`,不得显示千分位 |
| 整数 | `#,##0;(#,##0);"-"` |
| 小数 | `#,##0.0;(#,##0.0);"-"``#,##0.00;(#,##0.00);"-"` |
| 货币 | `$#,##0;($#,##0);"-"``$#,##0.00;($#,##0.00);"-"` |
| 百分比 | `0.0%``0.00%`,同一表内保持一致 |
| 估值倍数 | `0.0" x"` |
| 人数/股数 | `#,##0` |
零值显示为 `-`,负数使用括号法 `(123)`。如采用窄货币符号列,符号列单独放 `$` / `¥`,数值列不再带货币符号。
### 单位与来源
单位必须清晰标注:
- 全表单位统一时,在标题下方用灰色斜体副标题标注,如 `($ in Millions, Except Per Share Data)`
- 同一表存在多种单位时,在字段名后直接标注,如 `Revenue ($mm)``Gross Margin (%)``员工人数(万人)``ARPU, 元/月`
历史值、外部数据和硬编码来源必须在表尾或注释中标注:`Source: [来源], [日期], [页/表/字段]`。FY、CY、LTM、NTM、CAGR、YoY、QoQ 等缩写必须使用一致口径。
### 布局与列宽
如果 surface 支持隐藏默认网格线,财务模型应关闭网格线;如果当前 surface 不支持,则不把网格线作为阻塞验收项。
推荐像素宽度:
| 列类型 | 建议宽度 |
| --- | --- |
| 左侧留白列 | 16-24 px |
| 缩进/货币符号窄列 | 28-36 px |
| 标签/科目列 | 220-320 px |
| 年份/期间列 | 64-72 px最多约 80 px且所有年份列等宽 |
分区标题行用于隔离 Key Assumptions、Core Calculation、Terminal Value、Sensitivity 等区块:
- 同层级标题使用相同背景色和相同左右边界。
- 不同层级也保持左右边界一致,只用背景色深浅区分层级。
- 一级建议深蓝底白字加粗;二级中蓝;三级浅蓝。
父子层级通过缩进和加粗表达:父级/合计行加粗,子项正常字重并缩进。
### 边框
边框只表达结构,不做装饰。
| 场景 | 边框 |
| --- | --- |
| 小计行 | 上细线 |
| 关键小计,如 Gross Profit / EBITDA / NOPAT / UFCF | 上细线 + 加粗 |
| 最终合计,如 Net Income / Total Assets / Ending Cash / Implied Share Price | 上细线 + 下双线 + 加粗 |
| 普通数据行 | 无边框 |
| 年份列之间 | 禁止竖线 |
Sensitivity 矩阵可使用浅灰细框作为唯一例外,目的是表达双轴矩阵结构,不得扩展到普通财务报表区域。
### Sensitivity / Scenario
Sensitivity 必须极简、对称、可读。
禁止:
- 条件格式色阶。
- 数据条。
- 斑马纹。
- 图标集。
- 彩虹配色。
推荐:
1. 行轴和列轴以 baseline 为中心等距展开,如 WACC: 8%, 9%, 10%, 11%, 12%。
2. baseline 交点用浅黄背景 `#FFF2CC` + 加粗标注。
3. 所有结果格使用同一 `number_format`
4. 标题下方用灰色斜体说明输出指标。
## 交付前检查
任何财务输出必须检查:
- 已按任务类型选择规则,未过度套用复杂模型结构。
- 年份横向排布,历史/预测后缀清楚,如 A / E / B。
- 假设单独落地,蓝色字体,公式引用假设而非硬编码。
- 横向公式 `$` 锁定已回读验证。
- 蓝/黑/绿/红/灰色编码正确。
- 单位、币种、来源和口径已标注。
- 数字格式统一,负数括号,零值为 `-`,年份无千分位。
- 年份列等宽紧凑,标签列较宽。
- 普通数据行无装饰边框,无竖线。
- 小计/关键小计/最终合计的边框和加粗层级正确。
- 公式结果无错误值。
三表模型额外检查:
- BS 每期 `Total Assets = Total Liabilities + Total Equity`
- CFS Ending Cash 每期等于 BS Cash。
- Retained Earnings 与 Net Income / Dividends 口径一致。
多 sheet 模型额外检查:
- Assumptions 只放输入和说明,不写计算公式。
- Calc 引用 AssumptionsOutput 引用 Calc数据流单向。
- Sensitivity 独立 sheet 或独立区块。
Sensitivity 额外检查:
- 无色阶、无数据条、无斑马纹。
- 仅 baseline 交点高亮。
- 双轴范围围绕 baseline 对称。
## CLI 落地提示
- pandas / DataFrame 结果写入时读取 `lark-sheets-write-cells``+table-put`;目标表还不存在时读取 `lark-sheets-workbook``+workbook-create --sheets`
- 写值、公式、样式时读取 `lark-sheets-write-cells`;多区域或多步骤写入优先用 `lark-sheets-batch-update`
- 调整列宽、行高、合并、排序、复制格式时读取 `lark-sheets-range-operations``lark-sheets-sheet-structure`
- 生成跨 sheet 公式前读取 `lark-sheets-formula-translation`,并按飞书公式语法验证引用、数组和错误值。
- 所有颜色在 CLI 参数中使用带 `#` 的 RGB hex`#0000FF`

View File

@@ -40,6 +40,10 @@
**典型反例**:默认列宽 11 但内容含 12+ 字符的中文 / 含单位的数值(如 `109.10μmol/L`/ 长数字未设 `number_format` 显示为科学计数法 —— 用户在结果表里看不到完整原值。
**打印场景控制总宽(用户说"适合打印 / A4 / 打印范围"时必做)**:扩单列宽防截断的同时,**所有列宽之和要落在纸张可打印宽度内**——A4 横向约 ≤ 102 个半角字符(约 1000px纵向约 ≤ 70 个字符。超宽时不要无限加宽,改用 `cell_styles.word_wrap="auto-wrap"` + 调高行高,或缩窄非关键列,让整表在一页内(反例:总列宽远超 A4 可打印宽度,且长文本行高不够被截断)。
**只加宽承载新内容的列,不改动原有列的列宽**:列宽自适应**只针对新增 / 真正放不下新内容的列**;原表已有列的列宽**禁止重新计算、禁止缩小**——即便你估算的"理想宽度"与原值不同,只要原内容没被截断就不要动它。无差别地把所有列重设一遍宽度(哪怕只 ±1都属于破坏原文件视觉格式反例填完数据后顺手把原有列的列宽从 16 改成 17与原附件不一致破坏了原视觉格式
**⚠️ 合并单元格安全操作规则**`+cells-{merge|unmerge}` 必读):
1. **先读后写**:操作前必须用 `+sheet-info --include merges``+cells-get` 识别已有合并区域(特征:多个连续单元格中只有左上角有值,其余为空)。

View File

@@ -13,19 +13,22 @@
预探后必须在公式 / 筛选条件里用 `IFERROR` / `IFS` / 提取数值的辅助列处理所有变体;不能为了通过 head(10) 的样本就直接落地。一旦设计的逻辑只覆盖 sample 中出现的格式,就属于违规。
⚠️ **大数字15 位以上的身份证 / 参考号 / 流水号)做去重 / 比较时禁止用 `+csv-get` 的显示值**`+csv-get` 返回的是**格式化显示值**15 位以上数字会被显示成 `1.04E+14` 这类科学计数法——多个本不相同的号在显示层全变成同一个 `1.04E+14`,拿去判重会**整列误判为重复**。比较 / 去重 / 匹配大数字时必须改用 `+cells-get`(取原始精确值)或把该列读为文本,禁止用 csv-get 的科学计数显示值(反例:大批长参考号被显示成科学计数后,互不相同的号全变成同一个值,被当成整列重复并错误高亮)。
## 使用场景
读取。从飞书表格中读取单元格数据。本 reference 覆盖 3 个 shortcut按读取目的选择
读取。从飞书表格中读取单元格数据。本 reference 覆盖 4 个 shortcut按读取目的选择
| 读取目的 | 用这个 shortcut | 数据去向 | 说明 |
|---------|----------------|---------|------|
| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本( `--rows-json` 改为结构化 rows `{row_number, values:{列字母→值}}`);大表请按 `--range` 行窗口分批读(截断时看 `has_more` |
| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(每行带 `[row=N]` 前缀);大表请按 `--range` 行窗口分批读(截断时看 `has_more` |
| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put` | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[{name,type}]` + `rows`),列类型由 `number_format` 推断、混合列无损降 `string`;类型保真往返 |
| 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息token 开销较大 |
| 查看某区域的下拉框(数据验证)选项 | `+dropdown-get` | 对话上下文 | 返回该 A1 范围已配置的下拉列表选项 |
**选择原则**
- 只看值或做数据处理 → `+csv-get`;大表分批读取,避免一次拉全表撑爆上下文
-结构化、按 `row_number` / 列字母定位的输出 → `+csv-get --rows-json`(默认 CSV 串更省 token超大表批量仍用默认
-按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`)→ `+table-get`
- 需要公式/样式/批注 → `+cells-get`
- 只想知道某区域下拉框有哪些选项 → `+dropdown-get`
@@ -83,6 +86,7 @@
| `+cells-get` | read | 单元格 |
| `+dropdown-get` | read | 对象 |
| `+csv-get` | read | 单元格 |
| `+table-get` | read | 单元格 |
## Flags
@@ -115,7 +119,17 @@ _公共四件套 · 系统:`--dry-run`_
| `--max-chars` | int | optional | 防爆,默认 200000隐藏 flag不在 `--help` 列出,但可正常传入) |
| `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` |
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
| `--rows-json` | bool | optional | 返回结构化 rows`{row_number, values:{列字母→值}}`)而非 CSV 文本,默认 `false` |
### `+table-get`
_公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--sheet-id` | string | optional | 只读该子表(按 id省略则读所有子表 |
| `--sheet-name` | string | optional | 只读该子表(按名);省略则读所有子表 |
| `--range` | string | optional | 读取的 A1 范围;省略则读每个子表的当前数据区 |
| `--no-header` | bool | optional | 把第一行当数据而非表头(列名取 col1/col2 …) |
## Examples
@@ -140,17 +154,7 @@ lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细"
- `current_region` — 自动扩展到非空连续区域的 A1 范围。它是**真实数据边界****优先于 `+workbook-info``row_count`**`row_count` 是网格物理行数,常是 200 / 1000 等默认值、远大于实际数据;按它盲读会拉回大片空行)
- `has_more` — 是否截断;截断后续读用 `--range` 接着读
**加 `--rows-json`:返回结构化 rows而非 CSV 字符串)**
```bash
lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" --range "A1:G20" --rows-json
```
`--rows-json` 下的输出契约(替换 `annotated_csv` / `col_indices` / `row_indices`
- `rows` — 数组,每元素 `{row_number, values}``row_number` 是真实表格行号(整数,下游需要行号的操作直接取它);`values` 按**列字母** key`values["D"]`,绝对列字母)。**所有逻辑行都在 `rows` 里**。引号内换行已解析进单元格值,无需自己按 RFC-4180 拆行。
- `data_not_fully_read`**仅当没读全时出现**`{read_through_row, data_extends_through_row, unread_rows, reread_range}`。出现即表示真实数据超出本次读取范围;批量写入前必须按 `reread_range` 重读全区,否则漏行。
- 其余字段(`current_region` / `actual_range` / `has_more`)同上。
> 要按列类型结构化读出(喂 DataFrame、或 round-trip 回 `+table-put`)用 `+table-get`(见下);`+csv-get` 给的是带 `[row=N]` 前缀的纯值快照,下游需要行号/列坐标时直接从前缀与 `col_indices` 取。
### `+cells-get`
@@ -164,6 +168,27 @@ lark-cli sheets +cells-get --url "https://example.feishu.cn/sheets/shtXXX" --she
> ⚠️ 调用方在 `cells[i][j]` 中**不能**用下标推真实行列:必须读 `ranges[n].row_indices[i]` / `ranges[n].col_indices[j]`。
### `+table-get`(飞书 → DataFrame类型保真读出
`+table-put`(写入侧,见 write-cells reference的镜像把表格读回与 `--sheets` 同构的 typed 协议(`sheets[]` + `columns:[{name,type}]` + `rows`),可直接喂回 `+table-put` 或转 DataFrame。列 `type` 从每列 `number_format` 推断(日期格式→`date`、数值→`number``date` 列的序列号转回 ISO `yyyy-mm-dd`——日期、数字往返不丢类型。**列类型只在该列所有非空值一致时才定(`number` / `date` / `bool`);一列混了类型(如数字列混入「暂无」、日期列混入裸数字)会降为 `string`,让 `columns[].type``rows` 里每个值自洽——能 round-trip 回 `+table-put`、不让 pandas 崩。降级是无损的(脏值原样保留为文本);若要把零星脏值转成数值列,交给调用方在 pandas 侧做(`to_numeric(errors='coerce')`),那里原始值仍在、可追溯。** 底层复用 `get_cell_ranges` / `get_range_as_csv`。默认读所有子表、第一行当表头(`--no-header` 把首行当数据、列名取 `col1` / `col2` …)。
```bash
# 默认读所有子表 → sheets[](与 +table-put 的 --sheets 同构,可喂回或转 DataFrame
lark-cli sheets +table-get --url "<表URL>"
# 可选:--sheet-name / --sheet-id 限定只读某一个子表(不给则读全部)
lark-cli sheets +table-get --url "<表URL>" --sheet-name "销售"
```
`+table-get` 输出 → DataFrame按读回的 `type` 还原 dtype
```python
sheet = out["data"]["sheets"][0]
df = pd.DataFrame(sheet["rows"], columns=[c["name"] for c in sheet["columns"]])
for c in sheet["columns"]:
if c["type"] == "date": df[c["name"]] = pd.to_datetime(df[c["name"]])
elif c["type"] == "number": df[c["name"]] = pd.to_numeric(df[c["name"]])
```
### Validate / DryRun / Execute 约束
- `Validate` 阶段只做 XOR 检查、Enum 合法性、防爆参数上限校验;**禁止**联网(如不能用 `--sheet-name` 提前去查 `sheet-id`)。

View File

@@ -0,0 +1,41 @@
# Lark Sheet Recover
## 使用场景
写。整表回退到指定历史 revision方案 B。本 reference 覆盖 1 个 shortcut
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 整表回退到历史版本 | `+recover` | 把整个 spreadsheet 回退到 `--to-revision` 指定的版本,丢弃其后所有编辑 |
⚠️ **全文档破坏性回退**`+recover` 丢弃 `--to-revision` 之后的**所有**编辑,包括其他协作者的改动。只在 agent 自己的草稿表、或确认要整表回滚时使用。要精确撤销本链路最近几笔编辑,请用 `+undo`(逐笔、可控、不波及他人)。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+recover` | write | 撤销恢复 |
## Flags
### `+recover`
_公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--to-revision` | int | required | 整表回退到该 revision来自此前写操作返回的 revision |
## Examples
### `+recover`
把整表回退到某个历史版本。`--to-revision` 取此前某次写操作响应里返回的 `revision`
```bash
lark-cli sheets +recover --spreadsheet-token <token> --to-revision 42
```
- 回退会产生一个**新** revision不是删历史而是追加一条回退记录响应里返回这个新 revision。
- 回退不可逆地丢弃 `--to-revision` 之后的内容,执行前先确认要丢弃的范围。
-`+undo` 的区别:`+undo` 精确撤销本 CLI 链路最近 N 笔编辑、不动他人改动;`+recover` 是全文档回到某版本、丢弃所有后续(含他人)。需要细粒度撤销时优先 `+undo`

View File

@@ -6,6 +6,7 @@
## 最高优先级原则
- **用户指令优先**:用户明确提出的格式要求(如"使用红色背景")具有最高权重,即使与通用审美冲突。
- **金融/财务建模规范优先于通用视觉**:当任务是 DCF、三张表、预算、Variance、Sensitivity、估值、FP&A 等场景时,先读取 `lark-sheets-financial-modeling-standards`;其中关于斑马纹、色阶/数据条、年份列宽、竖线、颜色编码、数字格式的规定覆盖本文通用建议。
- **继承原表风格**:编辑前先采样原文件视觉特征(色系、边框、对齐、数字格式),新增内容必须与之对齐。严禁对已有风格的文件强行施加通用标准化格式。
- **扩展而非覆盖**:新增行列或追加数据时,目标是"扩展原模板"——继承邻近区域的表头风格、条纹节奏、边框层级、对齐方式、数字格式和列宽/行高策略。
- **美化只动样式属性,不动数据**:对**已有区域**做美化时,**只能**修改 `font` / `fill` / `border` / `alignment` / `number_format` 这 5 类样式属性。**禁止**改动原始单元格的 `value` / `formula`、合并区域、行列结构、Sheet 名称。如果美化需求需要改变数据布局(例如"汇总行加进表里"),必须把"加汇总行"和"美化"拆成两步,前者属于编辑动作、需另行得到用户授权。
@@ -24,6 +25,8 @@
**差异化标注场景**:用户要求"重复行 / 异常值 / 重要项视觉区分"时,标注列 / 行必须设置与普通数据**显著不同**的 `cell_styles`(背景色 + 加粗 + 字体色至少改一项),不能与普通数据格式完全一致。
**显式要求边框 / 表头 / 对齐时同样按上面标准落地**(不必等用户说"美化"):① 用户说"给某矩形区域加边框"必须**整个矩形含表头行、数据行、汇总行全部加内外框**,落地后核起 / 末行、末列三边界(反例:要求加边框的区域实际无任何边框);② **新建表头前先确认哪一行才是表头**——别把已有的第一行数据误当表头刷成蓝底白字,真正该加的表头列也要建出来(反例:把第一行数据误设成了表头样式);③ 新增 / 编辑区域的字号必须与原表一致,禁止 13 号与 14 号、10 号与 11 号混杂(反例:新列字号与原表不一致)。
## 通用样式规范
> 以下取值标准都在「最高优先级原则」的**继承原表风格 / 扩展而非覆盖**前提下生效:凡涉及"沿用原表"的条目,遵循该原则即可,本节不再逐条复述。
@@ -201,4 +204,3 @@ Step 3 — 微调收尾:`+batch-update` + `+rows-resize / +cols-resize` / `+ce
- 合并区域样式只写左上角,不要对合并内的其他单元格重复写入样式。
> 合并单元格完整的安全操作规则(含数据保护、样式占位等 5 条)见 `lark-sheets-range-operations` 的 `+cells-{merge|unmerge}` 章节。

View File

@@ -10,7 +10,7 @@
## 使用场景
读写。管理工作簿结构。本 reference 覆盖 11 个 shortcut
读写。管理工作簿结构。本 reference 覆盖 14 个 shortcut
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
@@ -41,8 +41,11 @@
| `+sheet-hide` | write | 工作簿 |
| `+sheet-unhide` | write | 工作簿 |
| `+sheet-set-tab-color` | write | 工作簿 |
| `+sheet-hide-gridline` | write | 工作簿 |
| `+sheet-show-gridline` | write | 工作簿 |
| `+workbook-create` | write | 工作簿 |
| `+workbook-export` | read | 工作簿 |
| `+workbook-import` | write | 工作簿 |
## Flags
@@ -59,7 +62,7 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--title` | string | required | 新工作表名称 |
| `--index` | int | optional | 插入位置;省略时附加到末尾 |
| `--index` | int | optional | 插入位置0-based;省略时附加到末尾 |
| `--row-count` | int | optional | 初始行数(默认 200上限 50000 |
| `--col-count` | int | optional | 初始列数(默认 20上限 200 |
@@ -115,6 +118,18 @@ _公共四件套 · 系统:`--dry-run`_
| --- | --- | --- | --- |
| `--color` | string | required | Hex 色值如 `#FF0000`,传空 `""` 清除 |
### `+sheet-hide-gridline`
_公共四件套 · 系统:`--dry-run`_
_仅含公共 / 系统 flag。_
### `+sheet-show-gridline`
_公共四件套 · 系统:`--dry-run`_
_仅含公共 / 系统 flag。_
### `+workbook-create`
_系统:`--dry-run`_
@@ -123,8 +138,9 @@ _系统`--dry-run`_
| --- | --- | --- | --- |
| `--title` | string | required | 新 spreadsheet 标题 |
| `--folder-token` | string | optional | 目标文件夹 token省略时放在云空间根目录 |
| `--headers` | string + File + Stdin简单 JSON | optional | 表头行 JSON 数组`["列A","列B"]` |
| `--values` | string + File + Stdin简单 JSON | optional | 初始数据 JSON 二维数组:`[["alice",95]]` |
| `--values` | string + File + Stdin简单 JSON | optional | untyped 初始数据,一个 JSON 二维数组(表头并入第一行)`[["列A","列B"],["alice",95]]`;值原样写入、类型由飞书自动识别,走与 --sheets 相同的分批 `+cells-set`;配 --styles 控制格式/颜色/合并/行列尺寸 |
| `--sheets` | string + File + Stdin复合 JSON | optional | 建表后写入的 typed 表格协议 JSON同 +table-put顶层 sheets 数组,每项 {name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}type 为 string/number/date/bool。与 --values 互斥;新表默认子表复用为第一个子表,日期/数字类型保真。 |
| `--styles` | string + File + Stdin复合 JSON | optional | 建表时同时写入的视觉处理操作 JSON顶层 `{styles:[...]}`,每项对应一个目标子表、含 `name`,并至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一。`cell_styles` 用 A1 单元格 range + 扁平样式字段(字段同 +cells-set-style含 number_format / 颜色 / 对齐 / border_stylesrow/col sizes 用行/列范围 + type/sizemerges 用单元格 range + 可选 merge_type。与 --sheets 搭配时 styles 数组长度/顺序/name 必须与 --sheets.sheets 对应;与 --values 搭配时只给一个 styles 项(其 name 忽略)。 |
### `+workbook-export`
@@ -136,6 +152,41 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| `--sheet-id` | string | optional | 仅 csv 模式必填:指定要导出哪张 sheet 为 CSV。这是 `+workbook-export` 专有 flag与公共四件套的 sheet 定位无关(本 shortcut 不接受公共 sheet 定位) |
| `--output-path` | string | optional | 本地保存路径;省略时只触发导出不下载 |
### `+workbook-import`
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--file` | string | required | 本地文件路径(.xlsx / .xls / .csv |
| `--folder-token` | string | optional | 目标文件夹 token省略则导入到云空间根目录 |
| `--name` | string | optional | 导入后表格名称;省略则用本地文件名(去掉扩展名) |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+workbook-create` `--sheets`
_一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入_
**数组项**(类型 object
- `name` (string) — 目标子表名
- `start_cell` (string?) — 写入起点单元格A1 记法,如 "B2"),默认 "A1"
- `mode` (enum?) — overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头 [overwrite / append]
- `header` (boolean?) — 是否写一行列名表头
- `allow_overwrite` (boolean?) — 为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success
- `columns` (array<object>) — 列定义,顺序与 rows 中每行的取值一一对应 each: { name: string, type: enum, format?: string }
- `rows` (array<array<string|number|boolean|null>>) — 数据行;每行是一个数组,长度必须等于 columns 数
### `+workbook-create` `--styles`
**数组项**(类型 object
- `cell_merges` (array<object>?) — 单元格合并操作数组range 使用 A1 单元格范围merge_type 默认 all each: { merge_type?: enum, range: string }
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_line?: enum, font_size?: number, …共 12 项 }
- `col_sizes` (array<object>?) — 列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size each: { range: string, size?: number, type: enum }
- `name` (string) — 子表名
- `row_sizes` (array<object>?) — 行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size each: { range: string, size?: number, type: enum }
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`XOR`+workbook-info` 只用前两者;`+sheet-*` 系列对单个工作表操作,需 `--sheet-id``--sheet-name`
@@ -144,6 +195,111 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
输出契约:返回 `sheets[]`,每个含 `sheet_id` / `title`(工作表显示名;旧 payload 用 `sheet_name`,读取时优先取 `title`、缺失再回退 `sheet_name`/ `row_count` / `column_count` / `index` / `is_hidden`,以及计数字段 `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count`(无 `frozen_*` 字段,冻结信息请用 `+sheet-info` 读取)。是操作飞书表格的第一步——任何后续 sheet 级动作都需要先拿这里的 sheet_id。
### `+workbook-create`
新建电子表格可选预填数据。两种数据入口untyped `--values` / typed `--sheets`**互斥**,按需二选一——两者都走同一条分批 `set_cell_range` 写入:
```bash
# 1) untyped--values一个二维数组表头并入第一行值原样写、类型由飞书自动识别
# 日期会落成文本,配 --styles 控制格式)
lark-cli sheets +workbook-create --title "销售" \
--values '[["门店","销售额"],["北京",259874]]'
# 2) typed--sheets一步建表 + 类型保真。date 列落成真日期(可排序/透视)、
# number 不丢精度、string 列保前导零(如订单号 00123多子表一次建。
lark-cli sheets +workbook-create --title "交易" --sheets '{
"sheets":[
{"name":"明细","columns":[
{"name":"日期","type":"date"},
{"name":"金额","type":"number","format":"#,##0.00"},
{"name":"单号","type":"string"}
],"rows":[["2024-01-15",1234.5,"00123"]]}
]}'
```
`--sheets` 协议与 `+table-put` 完全同构(字段含义见 lark-sheets-write-cells 的 `+table-put`,大 payload 走 stdin / `@file`)。关键差异:**新建工作簿的默认子表会被复用为第一个子表**(重命名后承载数据),不会残留空 `Sheet1`;其余子表按需新建。它把 `+table-put` 单独做不到的"建表 + typed 写入"合到一条命令是「pandas 算完直接落地一张带真日期的新表」的首选。回读校验用 `+table-get`(与 `--sheets` 同构、可 round-trip
`--styles` 可在建表写入时同时写视觉处理。它和 `--sheets` 一样只有一种外层写法:顶层对象里放 `styles` 数组;数组每项对应一个子表,含 `name`,并按能力拆成四类可选数组:
- `cell_styles`:像 `+cells-set-style`,用 A1 单元格 `range` 加扁平样式字段(`font_weight` / `background_color` / `number_format` 等)和可选 `border_styles`;这些样式会合并进同一次内容 `set_cell_range`
- `cell_merges`:用 A1 单元格 `range` 设置合并,`merge_type` 默认为 `all`,可选 `rows` / `columns`
- `row_sizes`:用行范围(如 `1:3`)设置行高,`type``pixel` / `standard` / `auto``pixel` 需要 `size`
- `col_sizes`:用列范围(如 `A:C`)设置列宽,`type``pixel` / `standard``pixel` 需要 `size`
同一单元格命中多个 `cell_styles` 项时,后面的操作继续合并覆盖已传字段。`cell_merges` / `row_sizes` / `col_sizes` 在内容写入后顺序执行。
```bash
# 3) untyped仍用 {"styles":[...]}只有一个子表样式项name 忽略range 覆盖 --values 初始区域
lark-cli sheets +workbook-create --title "销售" \
--values '[["门店","销售额"],["北京",259874],["上海",198320]]' \
--styles '{
"styles":[
{"name":"Sheet1","cell_styles":[
{"range":"A1:B1","font_weight":"bold","background_color":"#f5f5f5"},
{"range":"B2:B3","number_format":"#,##0"}
]}
]
}'
# 4) typed 单子表:--styles.styles[0].name 必须对应 --sheets.sheets[0].name
lark-cli sheets +workbook-create --title "交易" --sheets '{
"sheets":[
{"name":"明细","columns":[
{"name":"日期","type":"date"},
{"name":"金额","type":"number","format":"#,##0.00"}
],"rows":[["2024-01-15",1234.5]]}
]}' --styles '{
"styles":[
{"name":"明细",
"cell_styles":[
{"range":"A1:B1","font_weight":"bold","background_color":"#f5f5f5",
"border_styles":{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}},
{"range":"A2:A2","number_format":"yyyy-mm-dd"},
{"range":"B2:B2","number_format":"#,##0.00","font_color":"#0f7b0f"}
],
"cell_merges":[{"range":"A1:B1"}],
"col_sizes":[{"range":"A:B","type":"pixel","size":120}],
"row_sizes":[{"range":"1:1","type":"pixel","size":28}]}
]
}'
# 5) typed 多子表styles 数组和 sheets 数组长度、顺序、name 都必须一致
lark-cli sheets +workbook-create --title "经营看板" --sheets '{
"sheets":[
{"name":"收入","columns":[{"name":"月份","type":"string"},{"name":"收入","type":"number","format":"#,##0"}],"rows":[["2026-05",1200000]]},
{"name":"成本","columns":[{"name":"月份","type":"string"},{"name":"成本","type":"number","format":"#,##0"}],"rows":[["2026-05",730000]]}
]}' --styles '{
"styles":[
{"name":"收入","cell_styles":[
{"range":"A1:B1","font_weight":"bold","background_color":"#f0f7ff"},
{"range":"B2:B2","font_color":"#0f7b0f"}
]},
{"name":"成本","cell_styles":[
{"range":"A1:B1","font_weight":"bold","background_color":"#fff7ed"},
{"range":"B2:B2","font_color":"#b42318"}
]}
]
}'
```
> ⚠️ **`+workbook-create` 是把内存里的数据写成新表;要把已有的本地 Excel/CSV 文件原样导入成新表,用 `+workbook-import`**(见下),不要先在本地读出文件再 `+workbook-create` 重灌。
### `+workbook-import`
把已有的本地 `.xlsx` / `.xls` / `.csv` 文件导入为一个**新的**飞书电子表格(异步任务 + 内置轮询),与 `+workbook-export`(导出)对称。底层复用 drive 的导入实现,固定导入为电子表格类型。
```bash
# 导入到云空间根目录;表格名默认取本地文件名(去掉扩展名)
lark-cli sheets +workbook-import --file ./data.xlsx
# 指定目标文件夹与导入后表格名
lark-cli sheets +workbook-import --file ./report.csv --folder-token <FOLDER_TOKEN> --name "月度报表"
```
- **不接受任何 spreadsheet / sheet 定位 flag**(它是新建,不操作已有表):只有 `--file`(必填)/ `--folder-token` / `--name`
- 仅导入为电子表格sheet。若要把本地表格导入成多维表格bitable改用 `lark-cli drive +import --type bitable`
- 返回 `token` / `url`(导入完成的新表格)/ `ticket` / `ready` / `job_status`;未在内置轮询窗口内完成时返回 `timed_out=true` 与续查命令 `next_command`
### `+sheet-create`
示例:
@@ -190,8 +346,16 @@ lark-cli sheets +sheet-unhide --url "..." --sheet-id "$SID"
lark-cli sheets +sheet-set-tab-color --url "..." --sheet-id "$SID" --color "#FF0000"
```
### `+sheet-show-gridline` / `+sheet-hide-gridline`
```bash
# 切换子表网格线显隐;二态语义在命令名里,无需额外参数(同 +sheet-hide/+sheet-unhide
lark-cli sheets +sheet-show-gridline --url "..." --sheet-id "$SID"
lark-cli sheets +sheet-hide-gridline --url "..." --sheet-id "$SID"
```
### Validate / DryRun / Execute 约束
- `Validate`XOR 公共四件套;`+sheet-create` 校验 `--title` 非空、`--row-count` ≤ 50000、`--col-count` ≤ 200`+sheet-delete` 必须 `--yes``--dry-run`
- `Validate`XOR 公共四件套;`+sheet-create` 校验 `--title` 非空、`--row-count` ≤ 50000、`--col-count` ≤ 200`+sheet-delete` 必须 `--yes``--dry-run``+workbook-create``--sheets``--values` **互斥**,给了 `--sheets` 则按 typed 协议校验 payload其余约束同 `+table-put`
- `DryRun``+sheet-*` 写操作输出"将要 PATCH 的 sheet metadata"`--sheet-name` 在 dry-run 输出里生成为 `<resolve:Sheet1>` 占位符,不实际解析为 sheet-id。
- `Execute`:写操作不自动回读;如需确认目标 sheet 的新状态,自行调用 `+workbook-info`

View File

@@ -5,6 +5,7 @@
1. **明确写入边界**:写入前必须能回答"目标 range 的起止行列号是多少?是否落在用户授权范围内?"。除用户明示要修改的区域外,禁止扩张到原数据列以外或新建 Sheet。
2. **完整性断言**:批量写入前先把"预期写入条数"硬编码到代码里(如要填 106 条翻译 → `expected = 106`),写完后回读断言 `actual == expected`。少于预期就继续写,禁止交付半成品。
3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get``+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与本地脚本计算的预期值对照)。公式特定的"先验证模板再 --copy-to-range / 修完再读回"细则见下方相关章节。
4. **护原表 · 派生产物落点(写排名 / 标记 / 汇总 / 改写列时高频致命)**:派生结果一律写到**真实末列 +1 的全新空列**或新建子表,**禁止复用任何已有原数据列**——哪怕该列看起来"空",也要先 `+csv-get` 回读确认整列无原始数据再写。三条铁律:① 不把新公式 / 新值写进原数据列(典型反例:把新算的排名公式写进了原本存放另一份原始数据的列,整列原始数据被覆盖丢失);② 不改写、不合并原表头字段名(典型反例:把几个独立表头字段合并成一列,原字段名丢失);③ 慎用 `--allow-overwrite`:它一旦让写入区盖到相邻原始列 / 行就是不可逆数据丢失,加它之前必须用 `+sheet-info` / `+csv-get` 核清目标 range 不含任何原始数据。
## 新增列 / 新增行的样式继承(防止视觉风格不一致)
@@ -44,7 +45,30 @@
## 使用场景
写入。为一块单元格区域设置值、公式、批注/备注和/或格式。也支持通过 `rich_text``type: "embed-image"` 在单元格内嵌入图片(单元格图片)。关键:数组维度必须严格匹配——`cells` 二维数组必须与 `range` 的行列维度完全一致range 是闭区间,否则会触发 `InvalidCellRangeError`。计算示例:区域 `A1:D3` = 3 行 × 4 列 = `[[r1c1,r1c2,r1c3,r1c4],[r2c1,r2c2,r2c3,r2c4],[r3c1,r3c2,r3c3,r3c4]]`;区域 `A41:N48` = 8 行 × 14 列 = 8 个数组且每个数组 14 个单元;单个单元格 `A1` = `[[cell]]`;单列区域 `B5:B7` = `[[cell1],[cell2],[cell3]]`。空单元请使用 `{}`。**如果填写的区域存在大量重复内容,务必优先使用 `--copy-to-range` 字段复制,可大幅减少 `cells` 长度。**
写入。向飞书表格的单元格区域写入值、公式、样式、批注、图片或下拉,也可批量写入 CSV / DataFrame。本 reference 覆盖 6 个 shortcut按数据来源 + 内容形态选:
| 场景 | 用这个 shortcut | 原因 |
|------|----------------|------|
| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + `--start-cell`,不用自己拼二维 cells 数组;必要时自动扩容行列 |
| 列里有数值语义的数据(数字 / 金额 / 百分比 / 日期 / 计数)→ 飞书要类型保真来源不限DataFrame、Counter、dict、list 都算) | `+table-put` | 列显式声明 `type`date 落真日期、**金额 / 百分比 / 计数等数值列保精度且带 `number_format`(可排序 / 求和 / 入图表)**、string 保前导零,多 sheet 一次写。**只要列有数值语义就走这里**,不要在本地把数字拼成带 `$` / `%` 的字符串再走 `+csv-put` |
| 写入含样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整富字段的 shortcut公式 `+csv-put` 也能写) |
| 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag不触发不必要的值写入 |
| 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 |
| 大量纯值 + 需要表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 |
**优先级**:常规批量写入(纯值或公式)优先 `+csv-put`(最短入参,直接传 CSV 文本);含样式/批注/图片才用 `+cells-set`。⚠️ 这里"纯值"特指**已是文本、无需保留数值语义**的内容;只要列里是金额 / 百分比 / 日期 / 计数等有数值语义的数据,应优先 `+table-put`(声明 `number` / `date` 类型 + `number_format`),而不是 `+csv-put`
⚠️ `+csv-put` 可写值或公式:以 `=` 开头的单元格会被当作公式计算(读回时 `formula` 字段保留、`value` 为计算结果;含逗号的公式按 RFC 4180 用双引号包裹整列,如 `"=SUM(B2,C2)"`)。但**不会**携带样式/批注/图片,也无法把 `=` 开头的内容当字面量文本写入;需要样式/批注/图片用 `+cells-set`(或"写值 + 补样式"两步法)。
⚠️ **别把本该是数值的列格式化成字符串用 `+csv-put` 写入**(高频反模式):金额 / 百分比 / 市值 / 计数等列,若在本地拼成带 `$` / `%` / 千分位的字符串(如 `"$1,234.50"` / `"+30.5%"`)再 `+csv-put` 灌进去,单元格会变成**文本**——丢失排序 / 求和 / 图表 / 透视能力,且与 `number` 列混排时无法参与计算。正解是 `+table-put` 声明该列 `type:"number"`(百分比存小数,如 `0.305`+ `format`(如 `"$#,##0.00"` / `"0.0%"` / `"#,##0"`**显示效果完全相同、数值无损**。判断信号:**当你准备把一个数字 format 成字符串再写时,几乎总该用 `+table-put` 而非 `+csv-put`**。
⚠️ 大数据回写走"`+csv-get``--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。
## `+cells-set` 写入要点(高频模式 / 公式 / 样式)
> 以下是用 `+cells-set`(及 `+cells-set-style`)做富写入时的高频模式与铁律;选哪个 shortcut 见上方「使用场景」。
`+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text``type: "embed-image"` 嵌入单元格图片。**关键:`cells` 二维数组的行列维度必须与 `range`(闭区间)严格一致,否则触发 `InvalidCellRangeError`**——维度计算示例见文末 `## Schemas``--cells`
> **单元格图片 vs 浮动图片**
> - **单元格图片**(本工具):图片嵌入在单元格内部,属于单元格内容,随单元格移动。通过 `rich_text` 中 `type: "embed-image"` 写入。
@@ -96,6 +120,7 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl
5. **循环引用预检(高频致命错误)**写聚合公式SUM / AVERAGE / COUNT 等)前必须明确**引用范围不包含目标单元格自身或其传递依赖**。典型反例:在 C3 写 `=SUMIF(B:B,LEFT(B3,9)&"*",C:C)`B 列匹配 B3 前 9 位时 C3 自己也命中,导致 C3 自引用 → `~CIRCULAR~REF~`。修法:用辅助列 / 显式排除自身(`SUMIFS(C:C, B:B, ..., A:A, "<>"&A3)`/ 缩小范围避开自己
6. **REGEX 模式覆盖率验证**:公式里的 `REGEXEXTRACT` / `REGEXMATCH` / `REGEXREPLACE` 等正则模式落地前必须用本地脚本在源列上跑一遍命中率统计(`df[col].str.contains(pattern).mean()`);命中率 < 100% 时必须扩展 pattern 或加多分支IFS / 多个 IFERROR 串联)兜底,**禁止**只覆盖样本前 N 行就交付(典型反例:用 `REGEXEXTRACT(D5,"长(\d+)")` 只匹配带"长"前缀的尺寸文本,对"宽×高"、"×"、"*"等其它分隔符直接漏匹配)
7. **公式范围与用户指令字面对齐**:用户说"对 F 至 L 列求和"就必须写 `SUM(F2:L2)``F2+G2+H2+I2+J2+K2+L2`**不能漏列、多列、错列**。写完用 `+cells-get` 拿回 `formula` 字符串,与用户原话逐字对照(参与求和的列名一致 / 起止列号一致 / 运算符一致),不一致就是违规
8. **量纲 / 单位换算 / 数量乘项预检(高频致命错误,公式不报错但结果整体偏倍数)**:从文本提取数字做计算前,先核对**单位是否统一、是否漏乘数量、口径是否一致**——这类错误公式能跑通、无 `#` 报错,回读也看不出(值"像对的")。必须用本地脚本对 35 个代表行**离线手算一遍预期值**,与公式结果逐格比对量级:① 单位不一致先统一再算(典型反例:尺寸 `320CM*337CM` 直接取数相乘除以 1e6 得 0.11,正确是 CM→MM 换算后得 10.78**差 100 倍**);② 按"单件×数量"的量必须乘数量列(典型反例:侧面板面积漏乘 F 列数量F=2 的行只算了一半);③ 标准值口径对齐(典型反例:营养成分 mg/kg 与 g/100g 口径混用,整列放大 100 倍)。**口径 / 单位 / 数量任一项错,整列计算结果就是错的;这类错误公式不报错、回读也不易看出,必须靠离线手算对照。**
⚠️ **收到 `formula_errors` 反馈后不要只打补丁(高频致命错误)**`+cells-set` 返回值里若出现 `formula_errors: [{cell, formula, error_type, detail}]`,说明某些 cell 公式编译失败(`error_type=compile_failed` 通常是函数语法错如 `SPLIT(x)[1]` 的下标取值飞书不支持SPLIT 本身支持,取第 N 项用 `INDEX(SPLIT(...),N)``non_formula``=` 开头但解析不通过)。此时**禁止只聚焦修报错点的局部语法**(如仅把 `[1]` 换成 `INDEX(..,1)`),必须:
@@ -208,24 +233,6 @@ lark-cli sheets +dropdown-set \
`+dropdown-update`(多 range 批量更新)的所有 flag 语义与 `+dropdown-set` 完全一致;只是目标 `--ranges` 由单值变成 JSON 数组(每项带 sheet 前缀),同一份选项 + 配色应用到所有 range。
## 工具选择
本 skill 提供以下 CLI shortcut按数据来源 + 内容形态选:
| 场景 | 用这个 shortcut | 原因 |
|------|----------------|------|
| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + `--start-cell`,不用自己拼二维 cells 数组;必要时自动扩容行列 |
| 写入含公式、样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整字段的 shortcut |
| 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag不触发不必要的值写入 |
| 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 |
| 大量纯值 + 需要表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 |
**优先级**:常规纯值写入优先 `+csv-put`(最短入参,直接传 CSV 文本);含公式/样式/批注/图片才用 `+cells-set`
⚠️ `+csv-put` 只写纯值,**不会**携带公式/样式/批注/图片;公式字符串以 `=` 开头会被当作字面量文本落地。如果数据里需要公式或样式,**必须**用 `+cells-set`(或"写值 + 补样式"两步法)。
⚠️ 大数据回写走"`+csv-get``--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。
## Shortcuts
| Shortcut | Risk | 分组 |
@@ -235,6 +242,7 @@ lark-cli sheets +dropdown-set \
| `+cells-set-image` | write | 单元格 |
| `+dropdown-set` | write | 对象 |
| `+csv-put` | write | 单元格 |
| `+table-put` | write | 单元格 |
## Flags
@@ -299,10 +307,18 @@ _公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--start-cell` | string | required | 目标区域起点 A1`A1``B5`,不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet必须是单个单元格不接受范围写法终点按 CSV 实际行列数自动推断 |
| `--csv` | string + File + Stdin非 JSON 文本) | required | RFC 4180 CSV 文本;只写纯值,不带公式/样式/批注 |
| `--csv` | string + File + Stdin非 JSON 文本) | required | RFC 4180 CSV 文本;可写值或公式(以 = 开头的单元格按公式计算);不带样式 / 批注 / 图片,需要这些用 +cells-set。 |
| `--allow-overwrite` | bool | optional | 允许覆盖(默认 true设为 false 时若目标非空报错 |
| `--range` | string | optional | --start-cell 的别名(与 +csv-get / +cells-set 一致,用 --range 定位);传区间(如 A1:H17时自动取其左上角单元格隐藏 flag不在 `--help` 列出,但可正常传入) |
### `+table-put`
_公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--sheets` | string + File + Stdin复合 JSON | required | typed 表格协议 JSON顶层 sheets 数组,每项 {name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}type 为 string/number/date/bool |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
@@ -338,6 +354,19 @@ _列表选项_
**数组项**(类型 string
- 标量string
### `+table-put` `--sheets`
_一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入_
**数组项**(类型 object
- `name` (string) — 目标子表名
- `start_cell` (string?) — 写入起点单元格A1 记法,如 "B2"),默认 "A1"
- `mode` (enum?) — overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头 [overwrite / append]
- `header` (boolean?) — 是否写一行列名表头
- `allow_overwrite` (boolean?) — 为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success
- `columns` (array<object>) — 列定义,顺序与 rows 中每行的取值一一对应 each: { name: string, type: enum, format?: string }
- `rows` (array<array<string|number|boolean|null>>) — 数据行;每行是一个数组,长度必须等于 columns 数
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`XOR
@@ -413,15 +442,16 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \
--start-cell "A1" --csv @data.csv
```
> `+csv-put` 比 `+cells-set` 短得多——只想批量灌值时优先用它。需要式/样式才换 `+cells-set`。
> `+csv-put` 比 `+cells-set` 短得多——批量灌值或公式时优先用它。需要式/批注/图片才换 `+cells-set`。
>
> ⚠️ `=` 开头的字符串会被当字面量写入(**不会变公式**
> `=` 开头的单元格会被当作公式计算(不是字面量文本
>
> ```bash
> lark-cli sheets +csv-put --url "..." --sheet-name "Sheet1" \
> --start-cell "A1" \
> --csv $'name,score\nalice,=SUM(B2:B10)'
> # ↑ A2 实际写入字符串 "=SUM(B2:B10)"**不是公式**。需要写公式请用 +cells-set
> # ↑ B2 写入公式 =SUM(B2:B10),读回 formula 保留、value 为计算结果
> # 反过来:无法用 +csv-put 写「= 开头的字面量文本」(会被当公式);样式/批注/图片仍用 +cells-set。
> ```
> **定位 + 写入边界(关键,避免误覆盖)**
@@ -430,6 +460,44 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \
> - dry-run 与成功响应都回显 `writes_range`(实际落区,如 `B2:D4`**写前先 `--dry-run` 看一眼落区**,确认不会盖到相邻数据。
> - 要保护非空 cell`--allow-overwrite=false`(落区内出现非空 cell 即报错)。
### `+table-put`DataFrame → 飞书,类型保真写入)
把带类型的结构化数据DataFrame类型保真地写入**已有**表,底层复用 `set_cell_range`(同 `+cells-set`。typed 协议:顶层 `sheets[]`,每 sheet 带 `columns:[{name,type,format?}]` + `rows`(二维数组,`null`=空单元格),列 `type``string` / `number` / `date` / `bool`**显式声明**,不让 CLI 猜,避免邮编 / 订单号等"像数字的文本"被误判)。`date` 列的 ISO `yyyy-mm-dd` 字符串会转成 Excel 序列号 + 日期 `number_format`(真日期,可排序 / 透视 / 筛选)。
只写入**已有**表(`--url` / `--spreadsheet-token` 二选一必填),不新建工作簿——**要新建表格直接用 `+workbook-create --sheets`**(同 typed 协议、一步建表 + 类型保真写入,无需先建空表再回来,详见 workbook reference。读回用镜像命令 `+table-get`(见 read-data reference输出与 `--sheets` 同构、可 round-trip。
```bash
# sheet 按 name 匹配、缺则新建;多 DataFrame 经 stdin 一次写多 sheet
python export.py | lark-cli sheets +table-put --url "<表URL>" --sheets -
# 某 sheet 带 "mode":"append" 追加到已有数据末尾、默认不重复表头
lark-cli sheets +table-put --spreadsheet-token "<token>" --sheets @payload.json
```
每个 sheet 还可带 `"allow_overwrite": false`(遇非空拒写、保护原数据)、`"header": false`(只写数据不写表头)。完整字段跑 `+table-put --print-schema --flag-name sheets`
**前提:此 helper 需 pandas。** 注意一台机器常装多个 Python`python3` 未必指向装了 pandas 的那个——撞 `ModuleNotFoundError` 就换个解释器(如 `/usr/bin/python3`)再试。**不想依赖 pandas 也行**typed 协议就是纯 JSON直接手写 `columns` + `rows`(不经 helper一样喂给 `--sheets -`。DataFrame → 协议 的薄 helper一次清洗`NaN→null``Timestamp→ISO``numpy 标量→原生`
```python
import pandas as pd, numpy as np
def df_to_sheet(df, name, formats=None):
formats = formats or {}
def coltype(s):
if pd.api.types.is_datetime64_any_dtype(s): return "date"
if pd.api.types.is_bool_dtype(s): return "bool"
if pd.api.types.is_numeric_dtype(s): return "number"
return "string"
def cell(v):
if pd.isna(v): return None
if isinstance(v, pd.Timestamp): return v.date().isoformat()
if isinstance(v, np.generic): return v.item()
return v
columns = [{"name": str(c), "type": coltype(df[c]),
**({"format": formats[c]} if c in formats else {})} for c in df.columns]
rows = [[cell(v) for v in r] for r in df.itertuples(index=False, name=None)]
return {"name": name, "columns": columns, "rows": rows}
# payload = {"sheets": [df_to_sheet(df, "销售", {"日期": "yyyy-mm-dd"})]}json.dump 经 stdin 喂给 +table-put --sheets -
```
### Validate / DryRun / Execute 约束
- `Validate`XOR 公共四件套;`+cells-set``--cells` 必须能解析为 JSON 二维矩阵且行列数与 `--range` 完全一致;`+cells-set-style` 的样式 flag 至少一个非空(或带 `--border-styles``+cells-set-image``--range` 必须是单 cell起止 cell 相同);`+csv-put``--csv` 必须能按 RFC 4180 解析;防爆参数上限校验。

View File

@@ -143,14 +143,14 @@ func TestSheets_CRUDE2EWorkflow(t *testing.T) {
assert.True(t, len(matchedCells.Array()) > 0, "should find at least one cell containing 'Alice'")
})
t.Run("export spreadsheet with +export as bot", func(t *testing.T) {
t.Run("export spreadsheet with +workbook-export as bot", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
outputDir := t.TempDir()
outputPath := filepath.Join(outputDir, "export.xlsx")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+export",
"sheets", "+workbook-export",
"--spreadsheet-token", spreadsheetToken,
"--file-extension", "xlsx",
"--output-path", "./export.xlsx",