mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
21 Commits
codex/html
...
feat-recov
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f238c25e0 | ||
|
|
c95ab1c28e | ||
|
|
bed30c4ecb | ||
|
|
a7be567066 | ||
|
|
e96acad2c5 | ||
|
|
7ac8a7d30e | ||
|
|
31523b7f50 | ||
|
|
02a37029c2 | ||
|
|
556d7e3a77 | ||
|
|
f18a082a4f | ||
|
|
b8c5176483 | ||
|
|
82937a0a37 | ||
|
|
1cafb94a62 | ||
|
|
0b33daa136 | ||
|
|
5a61b97ac3 | ||
|
|
e01f2dfdd5 | ||
|
|
45f807459e | ||
|
|
8906e87fb1 | ||
|
|
d5a53d921d | ||
|
|
0ff7f0407e | ||
|
|
6e067f2180 |
@@ -39,230 +39,296 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveExportSpec(driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
})
|
||||
return ValidateExport(exportParamsFromFlags(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
// ExportParams holds the user-facing inputs for an export flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
|
||||
// the drive export implementation. An empty OutputDir means "create the export
|
||||
// task and poll, but do not download" — callers that only need the ready file
|
||||
// token / status get it back without writing a local file.
|
||||
type ExportParams struct {
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
SubID string
|
||||
OutputDir string
|
||||
FileName string
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
func (p ExportParams) spec() driveExportSpec {
|
||||
return driveExportSpec{
|
||||
Token: p.Token,
|
||||
DocType: p.DocType,
|
||||
FileExtension: p.FileExtension,
|
||||
SubID: p.SubID,
|
||||
}
|
||||
}
|
||||
|
||||
// exportParamsFromFlags reads the standard drive +export flag set.
|
||||
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
// drive +export always downloads; an empty --output-dir historically means
|
||||
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
|
||||
// it here to keep behavior identical and stay off the export-only ("" => skip
|
||||
// download) path that only sheets +workbook-export uses.
|
||||
outputDir := runtime.Str("output-dir")
|
||||
if outputDir == "" {
|
||||
outputDir = "."
|
||||
}
|
||||
return ExportParams{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OutputDir: outputDir,
|
||||
FileName: strings.TrimSpace(runtime.Str("file-name")),
|
||||
Overwrite: runtime.Bool("overwrite"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateExport runs the CLI-level export constraint checks.
|
||||
func ValidateExport(p ExportParams) error {
|
||||
return validateDriveExportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
|
||||
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
// RunExport drives create export task -> bounded poll -> optional download. It
|
||||
// is the shared core behind both drive +export and sheets +workbook-export. An
|
||||
// empty p.OutputDir skips the download step and returns the ready file token.
|
||||
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
|
||||
spec := p.spec()
|
||||
outputDir := p.OutputDir
|
||||
preferredFileName := strings.TrimSpace(p.FileName)
|
||||
overwrite := p.Overwrite
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
return err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
|
||||
// Export-only mode: caller wants the ready file token / metadata but
|
||||
// no local download (e.g. sheets +workbook-export without an output
|
||||
// path). Skip the download and return the status envelope.
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_token": status.FileToken,
|
||||
"file_name": status.FileName,
|
||||
"file_size": status.FileSize,
|
||||
"ready": true,
|
||||
"downloaded": false,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
},
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -488,6 +488,72 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
|
||||
// explicit empty --output-dir must still download to the current directory
|
||||
// (normalized to "."), not trigger the export-only no-download path that the
|
||||
// shared RunExport core uses for sheets +workbook-export.
|
||||
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_e",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0, "file_token": "box_e", "file_name": "report",
|
||||
"file_extension": "pdf", "type": "docx", "file_size": 3,
|
||||
},
|
||||
}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--output-dir", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Empty --output-dir must still write to cwd, not skip the download.
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
|
||||
if err != nil {
|
||||
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
|
||||
}
|
||||
if string(data) != "pdf" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"downloaded": false`) {
|
||||
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -34,128 +34,160 @@ var DriveImport = common.Shortcut{
|
||||
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveImportSpec(driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
})
|
||||
return ValidateImport(importParamsFromFlags(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
// ImportParams holds the user-facing inputs for an import flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-import) can reuse
|
||||
// the drive import implementation without taking a dependency on a --type flag.
|
||||
type ImportParams struct {
|
||||
File string
|
||||
DocType string
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string
|
||||
}
|
||||
|
||||
func (p ImportParams) spec() driveImportSpec {
|
||||
return driveImportSpec{
|
||||
FilePath: p.File,
|
||||
DocType: strings.ToLower(p.DocType),
|
||||
FolderToken: p.FolderToken,
|
||||
Name: p.Name,
|
||||
TargetToken: p.TargetToken,
|
||||
}
|
||||
}
|
||||
|
||||
// importParamsFromFlags reads the standard drive +import flag set.
|
||||
func importParamsFromFlags(runtime *common.RuntimeContext) ImportParams {
|
||||
return ImportParams{
|
||||
File: runtime.Str("file"),
|
||||
DocType: runtime.Str("type"),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateImport runs the CLI-level compatibility checks for an import.
|
||||
func ValidateImport(p ImportParams) error {
|
||||
return validateDriveImportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanImportDryRun builds the dry-run plan (upload -> create task -> poll) for
|
||||
// an import without performing any network or file I/O beyond a local stat.
|
||||
func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
}
|
||||
|
||||
// RunImport executes the full import flow: upload media -> create import task ->
|
||||
// bounded poll, then writes the result envelope to the runtime output. It is
|
||||
// the shared core behind both drive +import and sheets +workbook-import.
|
||||
func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportParams) error {
|
||||
spec := p.spec()
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
|
||||
// Keep dry-run and execution aligned on path normalization, file existence,
|
||||
// and format-specific size limits before planning the upload path.
|
||||
|
||||
@@ -177,6 +177,18 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
|
||||
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-show-gridline",
|
||||
sc: SheetShowGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-hide-gridline",
|
||||
sc: SheetHideGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dropdown-set",
|
||||
sc: DropdownSet,
|
||||
|
||||
@@ -152,6 +152,12 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
|
||||
}},
|
||||
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
|
||||
"+sheet-show-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "show_gridline")
|
||||
}},
|
||||
"+sheet-hide-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "hide_gridline")
|
||||
}},
|
||||
|
||||
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
|
||||
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -32,4 +32,6 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+range-sort": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
"+table-put": {},
|
||||
"+workbook-create": {},
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -164,12 +162,7 @@ var CsvGet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case runtime.Bool("rows-json"):
|
||||
// --rows-json reshapes the CSV response into structured rows
|
||||
// ({row_number, values:{col→cell}}); see assembleRowsJSON.
|
||||
out = assembleRowsJSON(out, strings.TrimSpace(runtime.Str("range")))
|
||||
case !runtime.Bool("include-row-prefix"):
|
||||
if !runtime.Bool("include-row-prefix") {
|
||||
out = stripRowPrefixFromCsvOutput(out)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
@@ -219,141 +212,6 @@ func stripRowPrefixFromCsvOutput(out interface{}) interface{} {
|
||||
return m
|
||||
}
|
||||
|
||||
// rowPrefixRe matches the leading "[row=N] " (or "[row=N],") annotation that
|
||||
// the tool prepends to the first physical line of each logical CSV record.
|
||||
var rowPrefixRe = regexp.MustCompile(`^\[row=(\d+)\][ ,]?`)
|
||||
|
||||
// assembleRowsJSON reshapes the tool's annotated_csv string into structured
|
||||
// rows so callers never have to regex-parse "[row=N]" or RFC-4180 CSV by hand:
|
||||
//
|
||||
// {
|
||||
// "range": "A1:K3380",
|
||||
// "current_region": "...", // passthrough, if the tool returned it
|
||||
// "rows": [{"row_number":1,"values":{"A":"姓名", ..., "K":"时间差_分钟"}},
|
||||
// {"row_number":2,"values":{"A":"张三", ..., "K":"8.5"}}, ...]
|
||||
// }
|
||||
//
|
||||
// Every logical row is emitted, including the first — no row is assumed to be a
|
||||
// header, since sheet data is not always tabular. Each cell is keyed by its
|
||||
// column letter (from the tool's col_indices when present, else derived from the
|
||||
// requested range's start column). On any parsing trouble it returns the
|
||||
// original output unchanged.
|
||||
func assembleRowsJSON(out interface{}, requestedRange string) interface{} {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
csvStr, ok := m["annotated_csv"].(string)
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
|
||||
// Group physical lines into logical records by [row=N] boundaries; lines
|
||||
// without a prefix are embedded-newline continuations of the current record.
|
||||
type logicalRow struct {
|
||||
num int
|
||||
text string
|
||||
}
|
||||
var groups []logicalRow
|
||||
for _, line := range strings.Split(csvStr, "\n") {
|
||||
if mm := rowPrefixRe.FindStringSubmatch(line); mm != nil {
|
||||
n, _ := strconv.Atoi(mm[1])
|
||||
groups = append(groups, logicalRow{num: n, text: line[len(mm[0]):]})
|
||||
} else if len(groups) > 0 {
|
||||
groups[len(groups)-1].text += "\n" + line
|
||||
}
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// Parse every logical row; widest row sets the column count. No row is
|
||||
// singled out as a header — that would assume the data is tabular, which it
|
||||
// often is not. The model reads row 1 like any other row and decides for
|
||||
// itself whether it is a header.
|
||||
parsed := make([][]string, len(groups))
|
||||
maxCols := 0
|
||||
for i, g := range groups {
|
||||
parsed[i] = parseCSVRecord(g.text)
|
||||
if len(parsed[i]) > maxCols {
|
||||
maxCols = len(parsed[i])
|
||||
}
|
||||
}
|
||||
if maxCols == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// Column letters key each cell. Prefer the tool's col_indices (authoritative,
|
||||
// length == col_count); otherwise derive from the requested range's start col.
|
||||
letters := coerceStringSlice(m["col_indices"])
|
||||
if len(letters) < maxCols {
|
||||
start := csvStartColIndex(requestedRange)
|
||||
letters = make([]string, maxCols)
|
||||
for j := 0; j < maxCols; j++ {
|
||||
letters[j] = csvColLetter(start + j)
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]map[string]interface{}, 0, len(groups))
|
||||
for i := range groups {
|
||||
fields := parsed[i]
|
||||
values := make(map[string]interface{}, len(letters))
|
||||
for j := range letters {
|
||||
v := ""
|
||||
if j < len(fields) {
|
||||
v = fields[j]
|
||||
}
|
||||
values[letters[j]] = v
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"row_number": groups[i].num,
|
||||
"values": values,
|
||||
})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{}
|
||||
for k, v := range m {
|
||||
result[k] = v
|
||||
}
|
||||
result["range"] = requestedRange
|
||||
result["rows"] = rows
|
||||
|
||||
// Surface the backend's "数据没读全" signal structurally instead of leaving it
|
||||
// buried in warning_message prose. The tool flags it when current_region (the
|
||||
// true data extent) reaches past actual_range (what was actually read) — the
|
||||
// single most important anti-under-read hint. Mirror that same comparison
|
||||
// (regionEndRow > actualEndRow) from the already-passthrough A1 ranges so the
|
||||
// model gets the real data range as a first-class field, never having to
|
||||
// parse it out of prose.
|
||||
if cr, _ := m["current_region"].(string); cr != "" {
|
||||
ar, _ := m["actual_range"].(string)
|
||||
regionEnd := a1EndRow(cr)
|
||||
readEnd := a1EndRow(ar)
|
||||
if regionEnd > 0 && readEnd > 0 && regionEnd > readEnd {
|
||||
result["data_not_fully_read"] = map[string]interface{}{
|
||||
"read_through_row": readEnd,
|
||||
"data_extends_through_row": regionEnd,
|
||||
"unread_rows": regionEnd - readEnd,
|
||||
"reread_range": cr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the fields whose information rows-json fully carries elsewhere:
|
||||
// - annotated_csv / row_indices / col_indices → reconstructed into
|
||||
// columns + rows (with integer row_number), losslessly.
|
||||
// - warning_message → its two halves are both handled: the static
|
||||
// "[row=N] / col_indices[j]" parse nag is moot once those fields exist,
|
||||
// and the dynamic "数据没读全" half is now the structured
|
||||
// data_not_fully_read field above. (Confirmed against the backend's
|
||||
// get-range-as-csv.ts — warning_message has no other content.)
|
||||
delete(result, "annotated_csv")
|
||||
delete(result, "row_indices")
|
||||
delete(result, "col_indices")
|
||||
delete(result, "warning_message")
|
||||
return result
|
||||
}
|
||||
|
||||
// a1EndRow extracts the ending row number from an A1 range, e.g. "A1:N51" → 51,
|
||||
// "Sheet1!B2:D9" → 9, "C5" → 5. Returns 0 when no row number is present.
|
||||
func a1EndRow(rng string) int {
|
||||
@@ -377,89 +235,6 @@ func a1EndRow(rng string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// parseCSVRecord parses a single logical CSV record (which may span multiple
|
||||
// physical lines via quoted embedded newlines) into its fields. An empty record
|
||||
// yields no fields; a malformed record falls back to a naive comma split so a
|
||||
// stray quote never drops a whole row.
|
||||
func parseCSVRecord(text string) []string {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
r := csv.NewReader(strings.NewReader(text))
|
||||
r.FieldsPerRecord = -1
|
||||
fields, err := r.Read()
|
||||
if err != nil {
|
||||
return strings.Split(text, ",")
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// coerceStringSlice returns v as []string when it is a homogeneous []interface{}
|
||||
// of strings (the shape of the tool's col_indices), else nil.
|
||||
func coerceStringSlice(v interface{}) []string {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
s, ok := e.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// csvStartColIndex returns the 0-based column index of a range's start column,
|
||||
// e.g. "A1:K3380" → 0, "C5:F9" → 2, "Sheet1!D2" → 3. Unparseable input → 0.
|
||||
func csvStartColIndex(rng string) int {
|
||||
rng = strings.TrimSpace(rng)
|
||||
if i := strings.LastIndex(rng, "!"); i >= 0 {
|
||||
rng = rng[i+1:]
|
||||
}
|
||||
var letters strings.Builder
|
||||
for _, c := range rng {
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') {
|
||||
letters.WriteRune(c)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if letters.Len() == 0 {
|
||||
return 0
|
||||
}
|
||||
return csvColToIndex(letters.String())
|
||||
}
|
||||
|
||||
// csvColToIndex converts a column letter to its 0-based index ("A"→0, "K"→10,
|
||||
// "AA"→26). Non-letter input → -1.
|
||||
func csvColToIndex(s string) int {
|
||||
n := 0
|
||||
for _, c := range strings.ToUpper(s) {
|
||||
if c < 'A' || c > 'Z' {
|
||||
break
|
||||
}
|
||||
n = n*26 + int(c-'A'+1)
|
||||
}
|
||||
return n - 1
|
||||
}
|
||||
|
||||
// csvColLetter converts a 0-based column index back to its letter (0→"A",
|
||||
// 25→"Z", 26→"AA"). Negative input → "".
|
||||
func csvColLetter(idx int) string {
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
var b []byte
|
||||
for idx >= 0 {
|
||||
b = append([]byte{byte('A' + idx%26)}, b...)
|
||||
idx = idx/26 - 1
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// DropdownGet wraps get_cell_ranges scoped to data_validation: read the
|
||||
// dropdown configuration on a range. Aligned with its sibling +cells-get
|
||||
// — sheet selection is via --sheet-id / --sheet-name (XOR), and --range
|
||||
|
||||
@@ -63,20 +63,6 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
"value_render_option": "formatted_value",
|
||||
},
|
||||
},
|
||||
{
|
||||
// --rows-json is post-processing on +csv-get's response; it must
|
||||
// NOT leak into the get_range_as_csv input.
|
||||
name: "+csv-get --rows-json builds the same input (flag is post-process)",
|
||||
sc: CsvGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--rows-json"},
|
||||
toolName: "get_range_as_csv",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "A1:C10",
|
||||
"max_rows": float64(unboundedReadLimit),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -179,113 +165,3 @@ func TestCsvGet_StripRowPrefix(t *testing.T) {
|
||||
t.Errorf("other field corrupted: %v", out["other"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON covers the --rows-json reshaping: every logical row
|
||||
// emitted (no header singled out), integer row_number, column-letter keyed
|
||||
// values, embedded newlines inside quoted fields, and current_region passthrough.
|
||||
func TestAssembleRowsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 姓名,备注,时间差_分钟\n[row=2] 张三,\"line1\nline2\",8.5\n[row=3] 李四,ok,3",
|
||||
"current_region": "A1:C3",
|
||||
"col_indices": []interface{}{"A", "B", "C"},
|
||||
"row_indices": []interface{}{1, 2, 3},
|
||||
"warning_message": "①定位行号…②定位列字母…",
|
||||
}
|
||||
out, ok := assembleRowsJSON(in, "A1:C3").(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("assembleRowsJSON did not return a map")
|
||||
}
|
||||
|
||||
// Fields whose info rows-json carries elsewhere are dropped (annotated_csv /
|
||||
// indices → rows; warning_message → moot static nag + structured
|
||||
// data_not_fully_read). Unrelated metadata like current_region is preserved.
|
||||
if _, exists := out["annotated_csv"]; exists {
|
||||
t.Errorf("annotated_csv should be dropped")
|
||||
}
|
||||
if _, exists := out["col_indices"]; exists {
|
||||
t.Errorf("col_indices should be dropped")
|
||||
}
|
||||
if _, exists := out["warning_message"]; exists {
|
||||
t.Errorf("warning_message should be dropped in rows-json mode")
|
||||
}
|
||||
if _, exists := out["columns"]; exists {
|
||||
t.Errorf("columns field should not exist (no header assumption)")
|
||||
}
|
||||
if out["current_region"] != "A1:C3" {
|
||||
t.Errorf("current_region passthrough lost: %v", out["current_region"])
|
||||
}
|
||||
|
||||
rows, _ := out["rows"].([]map[string]interface{})
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("want all 3 rows (incl. row 1), got %d: %+v", len(rows), rows)
|
||||
}
|
||||
// Row 1 is emitted as a normal row, not consumed as a header.
|
||||
if rows[0]["row_number"].(int) != 1 {
|
||||
t.Errorf("first row_number = %v, want 1", rows[0]["row_number"])
|
||||
}
|
||||
if v := rows[0]["values"].(map[string]interface{}); v["A"] != "姓名" || v["C"] != "时间差_分钟" {
|
||||
t.Errorf("row 1 values wrong: %+v", v)
|
||||
}
|
||||
// Row 2 keeps its embedded newline inside a single cell.
|
||||
v1 := rows[1]["values"].(map[string]interface{})
|
||||
if rows[1]["row_number"].(int) != 2 || v1["A"] != "张三" || v1["B"] != "line1\nline2" || v1["C"] != "8.5" {
|
||||
t.Errorf("row 2 wrong (embedded newline?): %+v", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON_DerivedLetters verifies cell letters are derived from the
|
||||
// range start when the tool omits col_indices (e.g. a C-anchored read).
|
||||
func TestAssembleRowsJSON_DerivedLetters(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=5] h1,h2\n[row=6] a,b",
|
||||
}
|
||||
out := assembleRowsJSON(in, "C5:D6").(map[string]interface{})
|
||||
rows := out["rows"].([]map[string]interface{})
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("want 2 rows, got %d", len(rows))
|
||||
}
|
||||
if rows[0]["row_number"].(int) != 5 {
|
||||
t.Errorf("first row_number = %v, want 5", rows[0]["row_number"])
|
||||
}
|
||||
if v := rows[0]["values"].(map[string]interface{}); v["C"] != "h1" || v["D"] != "h2" {
|
||||
t.Errorf("derived-letter values wrong: %+v", v)
|
||||
}
|
||||
if v := rows[1]["values"].(map[string]interface{}); v["C"] != "a" || v["D"] != "b" {
|
||||
t.Errorf("row 6 values wrong: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON_DataNotFullyRead verifies the structured under-read hint:
|
||||
// when current_region extends past actual_range, rows-json surfaces the true data
|
||||
// range as a first-class field (mirroring the backend's prose warning).
|
||||
func TestAssembleRowsJSON_DataNotFullyRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Read only A1:D2, but the data region reaches D4 → 2 rows unread.
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
|
||||
"actual_range": "A1:D2",
|
||||
"current_region": "A1:D4",
|
||||
}
|
||||
out := assembleRowsJSON(in, "A1:D2").(map[string]interface{})
|
||||
hint, ok := out["data_not_fully_read"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data_not_fully_read missing; out=%+v", out)
|
||||
}
|
||||
if hint["read_through_row"] != 2 || hint["data_extends_through_row"] != 4 ||
|
||||
hint["unread_rows"] != 2 || hint["reread_range"] != "A1:D4" {
|
||||
t.Errorf("data_not_fully_read wrong: %+v", hint)
|
||||
}
|
||||
|
||||
// Fully-read case: no hint emitted.
|
||||
in2 := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
|
||||
"actual_range": "A1:D2",
|
||||
"current_region": "A1:D2",
|
||||
}
|
||||
out2 := assembleRowsJSON(in2, "A1:D2").(map[string]interface{})
|
||||
if _, exists := out2["data_not_fully_read"]; exists {
|
||||
t.Errorf("data_not_fully_read should be absent when fully read")
|
||||
}
|
||||
}
|
||||
|
||||
85
shortcuts/sheets/lark_sheet_recover.go
Normal file
85
shortcuts/sheets/lark_sheet_recover.go
Normal 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
|
||||
}
|
||||
1148
shortcuts/sheets/lark_sheet_table_io.go
Normal file
1148
shortcuts/sheets/lark_sheet_table_io.go
Normal file
File diff suppressed because it is too large
Load Diff
991
shortcuts/sheets/lark_sheet_table_io_test.go
Normal file
991
shortcuts/sheets/lark_sheet_table_io_test.go
Normal file
@@ -0,0 +1,991 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// ─── pure helpers: date serial, typed cell mapping ────────────────────
|
||||
|
||||
func TestTablePut_IsoDateToSerial(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{"2024-01-15", 45306, true}, // the empirically verified anchor
|
||||
{"2024-01-01", 45292, true},
|
||||
{"2024-02-29", 45351, true}, // 2024 is a leap year
|
||||
{"1899-12-31", 1, true}, // one day after the epoch
|
||||
{"not-a-date", 0, false},
|
||||
{"2024/01/15", 0, false}, // wrong separator
|
||||
}
|
||||
for _, tt := range cases {
|
||||
got, err := isoDateToSerial(tt.in)
|
||||
if tt.ok {
|
||||
if err != nil {
|
||||
t.Errorf("isoDateToSerial(%q) unexpected error: %v", tt.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("isoDateToSerial(%q) = %d, want %d", tt.in, got, tt.want)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Errorf("isoDateToSerial(%q) = %d, want error", tt.in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablePut_BuildTypedCell(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("string keeps literal + text format so digit-like ids survive read-back", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell, err := buildTypedCell(&tableColumnSpec{Name: "id", Type: "string"}, "00123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cell["value"] != "00123" {
|
||||
t.Errorf("value = %#v, want \"00123\"", cell["value"])
|
||||
}
|
||||
if nf := numberFormatOf(cell); nf != "@" {
|
||||
t.Errorf("number_format = %q, want @ (text format so +table-get infers string, not number)", nf)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string stringifies a json.Number without scientific notation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell, _ := buildTypedCell(&tableColumnSpec{Name: "code", Type: "string"}, json.Number("123456789012345"))
|
||||
if cell["value"] != "123456789012345" {
|
||||
t.Errorf("value = %#v, want literal digits", cell["value"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("number preserves json.Number", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell, err := buildTypedCell(&tableColumnSpec{Name: "amt", Type: "number", Format: "#,##0"}, json.Number("259874"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, ok := cell["value"].(json.Number); !ok || n.String() != "259874" {
|
||||
t.Errorf("value = %#v, want json.Number 259874", cell["value"])
|
||||
}
|
||||
if nf := numberFormatOf(cell); nf != "#,##0" {
|
||||
t.Errorf("number_format = %q, want #,##0", nf)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date converts to serial + default format", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell, err := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date"}, "2024-01-15")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cell["value"] != 45306 {
|
||||
t.Errorf("value = %#v, want serial 45306", cell["value"])
|
||||
}
|
||||
if nf := numberFormatOf(cell); nf != "yyyy-mm-dd" {
|
||||
t.Errorf("number_format = %q, want default yyyy-mm-dd", nf)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date honors explicit format", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell, _ := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date", Format: "yyyy-mm"}, "2024-01-15")
|
||||
if nf := numberFormatOf(cell); nf != "yyyy-mm" {
|
||||
t.Errorf("number_format = %q, want yyyy-mm", nf)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bool maps to boolean", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell, err := buildTypedCell(&tableColumnSpec{Name: "b", Type: "bool"}, true)
|
||||
if err != nil || cell["value"] != true {
|
||||
t.Errorf("value = %#v (err=%v), want true", cell["value"], err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("null is an empty cell that still carries format", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell, err := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, has := cell["value"]; has {
|
||||
t.Errorf("null cell should have no value: %#v", cell)
|
||||
}
|
||||
if nf := numberFormatOf(cell); nf != "yyyy-mm-dd" {
|
||||
t.Errorf("null date cell should still carry format, got %q", nf)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type mismatches are rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := buildTypedCell(&tableColumnSpec{Type: "number"}, "abc"); err == nil {
|
||||
t.Error("number column accepting a string should error")
|
||||
}
|
||||
if _, err := buildTypedCell(&tableColumnSpec{Type: "date"}, json.Number("1")); err == nil {
|
||||
t.Error("date column accepting a number should error")
|
||||
}
|
||||
if _, err := buildTypedCell(&tableColumnSpec{Type: "bool"}, "true"); err == nil {
|
||||
t.Error("bool column accepting a string should error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// numberFormatOf digs the number_format out of a built cell's cell_styles, or
|
||||
// "" when absent.
|
||||
func numberFormatOf(cell map[string]interface{}) string {
|
||||
styles, ok := cell["cell_styles"].(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
nf, _ := styles["number_format"].(string)
|
||||
return nf
|
||||
}
|
||||
|
||||
// ─── payload validation ───────────────────────────────────────────────
|
||||
|
||||
func TestTablePut_PayloadValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
json string
|
||||
want string
|
||||
}{
|
||||
{"empty sheets", `{"sheets":[]}`, "at least one sheet"},
|
||||
{"missing name", `{"sheets":[{"columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "name is required"},
|
||||
{"duplicate name", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[]},{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "duplicate sheet name"},
|
||||
{"no columns", `{"sheets":[{"name":"S","columns":[],"rows":[]}]}`, "columns must be non-empty"},
|
||||
{"bad column type", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"timestamp"}],"rows":[]}]}`, "invalid type"},
|
||||
{"column missing name", `{"sheets":[{"name":"S","columns":[{"type":"string"}],"rows":[]}]}`, "columns[0].name is required"},
|
||||
{"row width mismatch", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"},{"name":"b","type":"string"}],"rows":[["x"]]}]}`, "column count"},
|
||||
{"bad start_cell", `{"sheets":[{"name":"S","start_cell":"A","columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "start_cell"},
|
||||
{"bad date value", `{"sheets":[{"name":"S","columns":[{"name":"d","type":"date"}],"rows":[["2025/03/31"]]}]}`, "must be ISO"},
|
||||
{"number expects numeric", `{"sheets":[{"name":"S","columns":[{"name":"n","type":"number"}],"rows":[["abc"]]}]}`, "number expects"},
|
||||
{"invalid json", `{not json`, "invalid JSON"},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseTablePutPayload(stubFlagView{"sheets": tt.json})
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Errorf("want error containing %q, got %v", tt.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stubFlagView is a minimal flagView backed by a map, for unit-testing the
|
||||
// payload parser without a cobra command.
|
||||
type stubFlagView map[string]string
|
||||
|
||||
func (s stubFlagView) Str(name string) string { return s[name] }
|
||||
func (s stubFlagView) Bool(name string) bool { return s[name] == "true" }
|
||||
func (s stubFlagView) Int(name string) int { return 0 }
|
||||
func (s stubFlagView) Float64(name string) float64 { return 0 }
|
||||
func (s stubFlagView) Changed(name string) bool { _, ok := s[name]; return ok }
|
||||
func (s stubFlagView) StrArray(name string) []string { return nil }
|
||||
func (s stubFlagView) StrSlice(name string) []string { return nil }
|
||||
func (s stubFlagView) Command() string { return "+table-put" }
|
||||
|
||||
// ─── dry-run: create + write rendering ────────────────────────────────
|
||||
|
||||
const tablePutSheetsJSON = `{"sheets":[{"name":"月度","columns":[` +
|
||||
`{"name":"门店","type":"string"},` +
|
||||
`{"name":"月份","type":"date","format":"yyyy-mm"},` +
|
||||
`{"name":"销售额","type":"number","format":"#,##0"}` +
|
||||
`],"rows":[["北京","2024-01-15",259874]]}]}`
|
||||
|
||||
func TestTablePut_DryRunWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL, "--sheets", tablePutSheetsJSON})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1 (set_cell_range only)", len(calls))
|
||||
}
|
||||
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
if input["excel_id"] != testToken {
|
||||
t.Errorf("excel_id = %v, want %s", input["excel_id"], testToken)
|
||||
}
|
||||
if input["sheet_name"] != "月度" {
|
||||
t.Errorf("sheet_name = %v, want 月度", input["sheet_name"])
|
||||
}
|
||||
if input["range"] != "A1:C2" {
|
||||
t.Errorf("range = %v, want A1:C2 (1 header + 1 data row × 3 cols)", input["range"])
|
||||
}
|
||||
rows := input["cells"].([]interface{})
|
||||
header := rows[0].([]interface{})
|
||||
if hs := cellStyles(header[0]); hs["font_weight"] != "bold" {
|
||||
t.Errorf("header cell should be bold, got %#v", header[0])
|
||||
}
|
||||
data := rows[1].([]interface{})
|
||||
// 月份 (date) → serial 45306, number_format yyyy-mm
|
||||
if v := cellValue(data[1]); v != float64(45306) {
|
||||
t.Errorf("date cell value = %#v, want 45306 serial", v)
|
||||
}
|
||||
if nf := cellStyles(data[1])["number_format"]; nf != "yyyy-mm" {
|
||||
t.Errorf("date number_format = %v, want yyyy-mm", nf)
|
||||
}
|
||||
// 销售额 (number) → 259874 preserved
|
||||
if v := cellValue(data[2]); v != float64(259874) {
|
||||
t.Errorf("number cell value = %#v, want 259874", v)
|
||||
}
|
||||
}
|
||||
|
||||
func cellValue(c interface{}) interface{} {
|
||||
m, _ := c.(map[string]interface{})
|
||||
return m["value"]
|
||||
}
|
||||
|
||||
func cellStyles(c interface{}) map[string]interface{} {
|
||||
m, _ := c.(map[string]interface{})
|
||||
s, _ := m["cell_styles"].(map[string]interface{})
|
||||
return s
|
||||
}
|
||||
|
||||
// ─── validation through the cobra surface ─────────────────────────────
|
||||
|
||||
func TestTablePut_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing spreadsheet locator rejected",
|
||||
args: []string{"--sheets", tablePutSheetsJSON},
|
||||
want: "at least one",
|
||||
},
|
||||
{
|
||||
name: "url and token are mutually exclusive",
|
||||
args: []string{"--url", testURL, "--spreadsheet-token", testToken, "--sheets", tablePutSheetsJSON},
|
||||
want: "mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "bad column type rejected",
|
||||
args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"foo"}],"rows":[]}]}`},
|
||||
want: "invalid type",
|
||||
},
|
||||
{
|
||||
name: "row width mismatch rejected",
|
||||
args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"},{"name":"b","type":"string"}],"rows":[["only-one"]]}]}`},
|
||||
want: "column count",
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, TablePut, append(tt.args, "--dry-run"))
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; got nil. stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), tt.want) {
|
||||
t.Errorf("error missing %q; got=%s|%s|%v", tt.want, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── execute paths with stubbed tools ─────────────────────────────────
|
||||
|
||||
// TestTablePut_ExecuteWrite drives the write path: a structure read maps the
|
||||
// existing sheet by name, then a set_cell_range write fills it.
|
||||
func TestTablePut_ExecuteWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"数据","index":0}]}`)
|
||||
write := toolOutputStub(testToken, "write", `{"updated_cells_count":2}`)
|
||||
out, err := runShortcutWithStubs(t, TablePut,
|
||||
[]string{"--url", testURL, "--sheets",
|
||||
`{"sheets":[{"name":"数据","columns":[{"name":"a","type":"string"},{"name":"b","type":"number"}],"rows":[["x",1]]}]}`},
|
||||
structure, write)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
sheets, _ := data["sheets"].([]interface{})
|
||||
if len(sheets) != 1 {
|
||||
t.Fatalf("result sheets = %d, want 1: %#v", len(sheets), data)
|
||||
}
|
||||
s0, _ := sheets[0].(map[string]interface{})
|
||||
if s0["name"] != "数据" || s0["sheet_id"] != testSheetID {
|
||||
t.Errorf("sheet summary = %#v, want name=数据 sheet_id=%s", s0, testSheetID)
|
||||
}
|
||||
if s0["range"] != "A1:B2" {
|
||||
t.Errorf("range = %v, want A1:B2", s0["range"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_ExecuteWriteCreatesMissingSheet covers the branch where the
|
||||
// named sheet does not yet exist: a create precedes the write.
|
||||
func TestTablePut_ExecuteWriteCreatesMissingSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
// First structure read sees only "Sheet1"; the payload targets "新表", so
|
||||
// createSheet runs, and the follow-up read (FIFO: second stub) resolves the
|
||||
// newly created sheet's id.
|
||||
structBefore := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0}]}`)
|
||||
structAfter := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"新表","index":1}]}`)
|
||||
write := toolOutputStub(testToken, "write", `{"ok":true}`)
|
||||
write.Reusable = true // modify_workbook_structure create + set_cell_range
|
||||
out, err := runShortcutWithStubs(t, TablePut,
|
||||
[]string{"--url", testURL, "--sheets",
|
||||
`{"sheets":[{"name":"新表","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`},
|
||||
structBefore, structAfter, write)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
sheets, _ := data["sheets"].([]interface{})
|
||||
if len(sheets) != 1 {
|
||||
t.Fatalf("result sheets = %d, want 1", len(sheets))
|
||||
}
|
||||
if s0, _ := sheets[0].(map[string]interface{}); s0["sheet_id"] != testSheetID2 {
|
||||
t.Errorf("created sheet id = %v, want %s", s0["sheet_id"], testSheetID2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_SheetCreateDims checks new-sheet sizing: small tables keep the
|
||||
// 20×200 floor (unchanged behavior), wide/long tables grow past it (the fix for
|
||||
// set_cell_range "exceeds sheet bounds"), and start_cell offset + header row are
|
||||
// accounted for, with columns clamped to the backend's 200 ceiling.
|
||||
func TestTablePut_SheetCreateDims(t *testing.T) {
|
||||
t.Parallel()
|
||||
bp := func(b bool) *bool { return &b }
|
||||
cols := func(n int) []tableColumnSpec { return make([]tableColumnSpec, n) }
|
||||
rows := func(n int) [][]interface{} { return make([][]interface{}, n) }
|
||||
cases := []struct {
|
||||
name string
|
||||
spec tableSheetSpec
|
||||
wantRows, wantCols int
|
||||
}{
|
||||
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, 200, 20},
|
||||
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, 200, 37},
|
||||
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, 501, 20},
|
||||
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, 200, 42},
|
||||
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, 500, 20},
|
||||
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, 200, 200},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gotRows, gotCols := sheetCreateDims(&tt.spec)
|
||||
if gotRows != tt.wantRows || gotCols != tt.wantCols {
|
||||
t.Errorf("sheetCreateDims = (%d rows, %d cols), want (%d, %d)", gotRows, gotCols, tt.wantRows, tt.wantCols)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_ExecuteCreatesWideSheetWithDims is the regression test for the
|
||||
// wide-table bug: a 25-column payload targeting a not-yet-existing sheet must
|
||||
// create it with 25 columns (past the 20-column default) so the follow-up
|
||||
// set_cell_range fits instead of failing with "exceeds sheet bounds".
|
||||
func TestTablePut_ExecuteCreatesWideSheetWithDims(t *testing.T) {
|
||||
t.Parallel()
|
||||
structBefore := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0}]}`)
|
||||
createStub := toolOutputStub(testToken, "write", `{"ok":true}`) // modify_workbook_structure create
|
||||
structAfter := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"宽表","index":1}]}`)
|
||||
writeStub := toolOutputStub(testToken, "write", `{"ok":true}`) // set_cell_range
|
||||
const n = 25
|
||||
cols := strings.TrimRight(strings.Repeat(`{"name":"c","type":"string"},`, n), ",")
|
||||
vals := strings.TrimRight(strings.Repeat(`"x",`, n), ",")
|
||||
payload := `{"sheets":[{"name":"宽表","columns":[` + cols + `],"rows":[[` + vals + `]]}]}`
|
||||
out, err := runShortcutWithStubs(t, TablePut,
|
||||
[]string{"--url", testURL, "--sheets", payload},
|
||||
structBefore, createStub, structAfter, writeStub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
var wire map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &wire); err != nil {
|
||||
t.Fatalf("decode create body: %v", err)
|
||||
}
|
||||
var input map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
|
||||
t.Fatalf("decode create tool input: %v", err)
|
||||
}
|
||||
if input["operation"] != "create" {
|
||||
t.Fatalf("first write should be the create op, got %#v", input["operation"])
|
||||
}
|
||||
if input["columns"] != float64(n) {
|
||||
t.Errorf("create columns = %#v, want %d (sized to the wide payload)", input["columns"], n)
|
||||
}
|
||||
if input["rows"] != float64(200) {
|
||||
t.Errorf("create rows = %#v, want 200 (floor)", input["rows"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_ExecutePartialFailure covers the partial-success error path:
|
||||
// a set_cell_range write fails mid-import and the structured error surfaces.
|
||||
// TestTablePut_ExecuteTotalFailure: a single sheet whose write fails landed
|
||||
// nothing — it must be a plain failure, NOT partial_success.
|
||||
func TestTablePut_ExecuteTotalFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"数据","index":0}]}`)
|
||||
writeErr := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_write",
|
||||
Body: map[string]interface{}{"code": 1254000, "msg": "boom"},
|
||||
}
|
||||
out, err := runShortcutWithStubs(t, TablePut,
|
||||
[]string{"--url", testURL, "--sheets",
|
||||
`{"sheets":[{"name":"数据","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`},
|
||||
structure, writeErr)
|
||||
if err == nil {
|
||||
t.Fatalf("expected failure; got nil. out=%s", out)
|
||||
}
|
||||
if strings.Contains(err.Error(), "partially applied") || strings.Contains(out, "partially applied") {
|
||||
t.Errorf("single-sheet failure must NOT be partial_success; got err=%v out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed") && !strings.Contains(out, "no sheets were written") {
|
||||
t.Errorf("expected plain-failure message; got err=%v out=%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_ExecutePartialFailure: first sheet's write lands, second fails →
|
||||
// partial_success carrying the first sheet in written_sheets.
|
||||
func TestTablePut_ExecutePartialFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
structure := toolOutputStub(testToken, "read",
|
||||
`{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"汇总","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"明细","index":1}]}`)
|
||||
writeOK := toolOutputStub(testToken, "write", `{"updated_cells_count":2}`)
|
||||
writeErr := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_write",
|
||||
Body: map[string]interface{}{"code": 1254000, "msg": "boom"},
|
||||
}
|
||||
out, err := runShortcutWithStubs(t, TablePut,
|
||||
[]string{"--url", testURL, "--sheets",
|
||||
`{"sheets":[{"name":"汇总","columns":[{"name":"a","type":"string"}],"rows":[["x"]]},{"name":"明细","columns":[{"name":"a","type":"string"}],"rows":[["y"]]}]}`},
|
||||
structure, writeOK, writeErr)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial-success error; got nil. out=%s", out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "partially applied") && !strings.Contains(out, "partially applied") {
|
||||
t.Errorf("expected partial_success (not total failure); got err=%v out=%s", err, out)
|
||||
}
|
||||
// The failing sheet is named in the message; the written one lives in the
|
||||
// structured written_sheets detail.
|
||||
if !strings.Contains(err.Error(), "明细") {
|
||||
t.Errorf("partial_success should name the failed sheet 明细; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── +workbook-create typed --sheets path ─────────────────────────────
|
||||
|
||||
// TestWorkbookCreate_TypedMutualExclusion locks the Validate contract: the typed
|
||||
// --sheets entry can't be combined with the untyped --headers/--values.
|
||||
func TestWorkbookCreate_TypedMutualExclusion(t *testing.T) {
|
||||
t.Parallel()
|
||||
typed := `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"sheets+headers", []string{"--title", "X", "--sheets", typed, "--headers", `["a"]`}},
|
||||
{"sheets+values", []string{"--title", "X", "--sheets", typed, "--values", `[["x"]]`}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, tc.args)
|
||||
if err == nil {
|
||||
t.Fatalf("expected mutual-exclusion error; got nil (stderr=%s)", stderr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("want 'mutually exclusive' error; got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookCreate_EmptySheetsErrors locks the fix for an explicitly-given but
|
||||
// empty --sheets (e.g. empty stdin / file): it must error, not silently fall
|
||||
// through to creating an empty workbook.
|
||||
func TestWorkbookCreate_EmptySheetsErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, []string{"--title", "X", "--sheets", ""})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for empty --sheets; got nil (stderr=%s)", stderr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty") {
|
||||
t.Errorf("want 'empty' error; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookCreate_TypedAdoptsDefaultSheet covers the one-step typed create:
|
||||
// the new workbook's default sheet is renamed to the first payload sheet's name
|
||||
// and reused (no empty Sheet1 left behind), then written type-faithfully (the
|
||||
// date lands as an Excel serial, not text).
|
||||
func TestWorkbookCreate_TypedAdoptsDefaultSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
create := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"spreadsheet": map[string]interface{}{"spreadsheet_token": "shtTYPED", "title": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
// lookupFirstSheetID and writeTypedSheets' listSheetIDsByName both read the
|
||||
// structure; one reusable stub serves both, reporting only the default sheet.
|
||||
structure := toolOutputStub("shtTYPED", "read", `{"sheets":[{"sheet_id":"shtDef","sheet_name":"Sheet1","index":0}]}`)
|
||||
structure.Reusable = true
|
||||
rename := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheet_ai/v2/spreadsheets/shtTYPED/tools/invoke_write",
|
||||
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), "modify_workbook_structure") },
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"output": `{"ok":true}`}},
|
||||
}
|
||||
write := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheet_ai/v2/spreadsheets/shtTYPED/tools/invoke_write",
|
||||
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), "set_cell_range") },
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"output": `{"updated_cells_count":4}`}},
|
||||
}
|
||||
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
|
||||
"--title", "Demo",
|
||||
"--sheets", `{"sheets":[{"name":"Sales","columns":[{"name":"d","type":"date"},{"name":"amt","type":"number"}],"rows":[["2024-01-15",1234.5]]}]}`,
|
||||
}, create, structure, rename, write)
|
||||
if err != nil {
|
||||
t.Fatalf("typed create failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtTYPED" {
|
||||
t.Errorf("spreadsheet_token = %v, want shtTYPED", data["spreadsheet"])
|
||||
}
|
||||
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
|
||||
t.Fatalf("want 1 written sheet, got %#v", data["sheets"])
|
||||
}
|
||||
// Default sheet adopted: rename targets shtDef → "Sales" (no new sheet, no
|
||||
// stray Sheet1).
|
||||
renameInput := decodeToolInput(t, decodeRawEnvelopeBody(t, rename.CapturedBody), "modify_workbook_structure")
|
||||
if renameInput["operation"] != "rename" || renameInput["sheet_id"] != "shtDef" || renameInput["new_name"] != "Sales" {
|
||||
t.Errorf("rename should adopt default shtDef→Sales; got %#v", renameInput)
|
||||
}
|
||||
// The data write carries the date as serial 45306, proving the type-faithful path.
|
||||
writeInput := decodeToolInput(t, decodeRawEnvelopeBody(t, write.CapturedBody), "set_cell_range")
|
||||
cellsJSON, _ := json.Marshal(writeInput["cells"])
|
||||
if !strings.Contains(string(cellsJSON), "45306") {
|
||||
t.Errorf("date 2024-01-15 should be written as serial 45306; cells=%s", cellsJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookCreate_TypedDryRun verifies the dry-run previews create + a typed
|
||||
// set_cell_range write with the date already converted to a serial.
|
||||
func TestWorkbookCreate_TypedDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Demo",
|
||||
"--sheets", `{"sheets":[{"name":"S","columns":[{"name":"d","type":"date"}],"rows":[["2024-01-15"]]}]}`,
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("want 2 dry-run calls (create + typed write), got %d", len(calls))
|
||||
}
|
||||
raw, _ := json.Marshal(calls[1])
|
||||
if !strings.Contains(string(raw), "45306") {
|
||||
t.Errorf("typed dry-run write should contain serial 45306; got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablePut_StringifyCellValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in interface{}
|
||||
want string
|
||||
}{
|
||||
{"plain", "plain"},
|
||||
{json.Number("12345678901234"), "12345678901234"},
|
||||
{true, "TRUE"},
|
||||
{false, "FALSE"},
|
||||
{3.5, "3.5"},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
if got := stringifyCellValue(tt.in); got != tt.want {
|
||||
t.Errorf("stringifyCellValue(%#v) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablePut_DescribeJSONType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in interface{}
|
||||
want string
|
||||
}{
|
||||
{"x", "a string"},
|
||||
{json.Number("1"), "a number"},
|
||||
{true, "a boolean"},
|
||||
{[]interface{}{}, "an array"},
|
||||
{map[string]interface{}{}, "an object"},
|
||||
{3.14, "float64"},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
if got := describeJSONType(tt.in); got != tt.want {
|
||||
t.Errorf("describeJSONType(%#v) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablePut_HeaderAndMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
bp := func(b bool) *bool { return &b }
|
||||
// headerOn: overwrite writes header, append omits it by default, explicit wins
|
||||
if !headerOn(&tableSheetSpec{}) {
|
||||
t.Error("overwrite default should write header")
|
||||
}
|
||||
if headerOn(&tableSheetSpec{Mode: "append"}) {
|
||||
t.Error("append should omit header by default")
|
||||
}
|
||||
if !headerOn(&tableSheetSpec{Mode: "append", Header: bp(true)}) {
|
||||
t.Error("explicit header:true should override append default")
|
||||
}
|
||||
if headerOn(&tableSheetSpec{Header: bp(false)}) {
|
||||
t.Error("explicit header:false should be honored")
|
||||
}
|
||||
// writeModeName
|
||||
if writeModeName(&tableSheetSpec{}) != "overwrite" || writeModeName(&tableSheetSpec{Mode: "append"}) != "append" {
|
||||
t.Error("writeModeName normalization wrong")
|
||||
}
|
||||
// buildSheetMatrix header toggle
|
||||
s := &tableSheetSpec{Columns: []tableColumnSpec{{Name: "a", Type: "string"}}, Rows: [][]interface{}{{"x"}}}
|
||||
if m, _ := buildSheetMatrix(s, true, false); len(m) != 1 {
|
||||
t.Errorf("header off → 1 data row, got %d", len(m))
|
||||
}
|
||||
if m, _ := buildSheetMatrix(s, true, true); len(m) != 2 {
|
||||
t.Errorf("header on → header + 1 data row, got %d", len(m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablePut_BadModeRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseTablePutPayload(stubFlagView{"sheets": `{"sheets":[{"name":"S","mode":"upsert","columns":[{"name":"a","type":"string"}],"rows":[]}]}`})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid") {
|
||||
t.Errorf("mode \"upsert\" should be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_AppendEmptySheetWritesHeader: appending to an EMPTY sheet still
|
||||
// writes the header row, so column names aren't lost (and a later +table-get
|
||||
// won't consume the first data row as the header).
|
||||
func TestTablePut_AppendEmptySheetWritesHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"新","index":0}]}`)
|
||||
region := toolOutputStub(testToken, "read", `{}`) // empty sheet: no current_region → lastRow 0
|
||||
write := toolOutputStub(testToken, "write", `{"ok":true}`)
|
||||
out, err := runShortcutWithStubs(t, TablePut,
|
||||
[]string{"--url", testURL, "--sheets",
|
||||
`{"sheets":[{"name":"新","mode":"append","columns":[{"name":"列A","type":"string"}],"rows":[["x"],["y"]]}]}`},
|
||||
structure, region, write)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
var wire map[string]interface{}
|
||||
if err := json.Unmarshal(write.CapturedBody, &wire); err != nil {
|
||||
t.Fatalf("decode captured write body: %v", err)
|
||||
}
|
||||
var input map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
|
||||
t.Fatalf("decode tool input: %v", err)
|
||||
}
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
if len(cells) != 3 {
|
||||
t.Fatalf("empty-sheet append should write header + 2 data rows = 3, got %d", len(cells))
|
||||
}
|
||||
if header, _ := cells[0].([]interface{}); len(header) > 0 {
|
||||
if h0, _ := header[0].(map[string]interface{}); h0["value"] != "列A" {
|
||||
t.Errorf("first row should be the header 列A; got %#v", h0)
|
||||
}
|
||||
}
|
||||
if input["range"] != "A1:A3" {
|
||||
t.Errorf("range = %v, want A1:A3 (header + 2 rows at top of empty sheet)", input["range"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_ExecuteAppend verifies append placement: data lands below the
|
||||
// sheet's existing data (current_region A1:B5 → start at row 6) with no repeated
|
||||
// header.
|
||||
func TestTablePut_ExecuteAppend(t *testing.T) {
|
||||
t.Parallel()
|
||||
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"日志","index":0}]}`)
|
||||
region := toolOutputStub(testToken, "read", `{"current_region":"A1:B5","actual_range":"A1:B5"}`)
|
||||
write := toolOutputStub(testToken, "write", `{"ok":true}`)
|
||||
out, err := runShortcutWithStubs(t, TablePut,
|
||||
[]string{"--url", testURL, "--sheets",
|
||||
`{"sheets":[{"name":"日志","mode":"append","columns":[{"name":"时间","type":"string"},{"name":"值","type":"number"}],"rows":[["t1",1],["t2",2]]}]}`},
|
||||
structure, region, write)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
// inspect the set_cell_range request the append produced
|
||||
var wire map[string]interface{}
|
||||
if err := json.Unmarshal(write.CapturedBody, &wire); err != nil {
|
||||
t.Fatalf("decode captured write body: %v", err)
|
||||
}
|
||||
var input map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
|
||||
t.Fatalf("decode tool input: %v", err)
|
||||
}
|
||||
if input["range"] != "A6:B7" {
|
||||
t.Errorf("append range = %v, want A6:B7 (2 rows below last data row 5, no header)", input["range"])
|
||||
}
|
||||
if cells, _ := input["cells"].([]interface{}); len(cells) != 2 {
|
||||
t.Errorf("append should write 2 data rows (no header), got %d", len(cells))
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if s0, _ := data["sheets"].([]interface{})[0].(map[string]interface{}); s0["mode"] != "append" {
|
||||
t.Errorf("summary mode = %v, want append", s0["mode"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_HeaderFalseAndAllowOverwrite checks header:false drops the
|
||||
// header row and allow_overwrite:false reaches the tool input.
|
||||
func TestTablePut_HeaderFalseAndAllowOverwrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL, "--sheets",
|
||||
`{"sheets":[{"name":"S","header":false,"allow_overwrite":false,"columns":[{"name":"a","type":"string"}],"rows":[["x"],["y"]]}]}`})
|
||||
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
if input["allow_overwrite"] != false {
|
||||
t.Errorf("allow_overwrite = %v, want false", input["allow_overwrite"])
|
||||
}
|
||||
rows, _ := input["cells"].([]interface{})
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("header:false → 2 data rows only, got %d", len(rows))
|
||||
}
|
||||
first, _ := rows[0].([]interface{})[0].(map[string]interface{})
|
||||
if first["value"] != "x" {
|
||||
t.Errorf("header:false first cell = %v, want data 'x' (no header row)", first["value"])
|
||||
}
|
||||
}
|
||||
|
||||
// ─── +table-get ───────────────────────────────────────────────────────
|
||||
|
||||
func TestTableGet_SerialRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, iso := range []string{"2024-01-15", "2024-02-29", "2000-01-01", "1899-12-31"} {
|
||||
s, err := isoDateToSerial(iso)
|
||||
if err != nil {
|
||||
t.Fatalf("isoDateToSerial(%s): %v", iso, err)
|
||||
}
|
||||
if back := serialToISO(float64(s)); back != iso {
|
||||
t.Errorf("roundtrip %s → %d → %s", iso, s, back)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableGet_IsDateNumberFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, nf := range []string{"yyyy-mm-dd", "yyyy-mm", "yyyy/m/d", "YYYY/MM/DD"} {
|
||||
if !isDateNumberFormat(nf) {
|
||||
t.Errorf("%q should be a date format", nf)
|
||||
}
|
||||
}
|
||||
for _, nf := range []string{"#,##0", "0.00", "0.00%", "@", ""} {
|
||||
if isDateNumberFormat(nf) {
|
||||
t.Errorf("%q should not be a date format", nf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableGet_InferColumnType(t *testing.T) {
|
||||
t.Parallel()
|
||||
mk := func(v interface{}, nf string) map[string]interface{} {
|
||||
c := map[string]interface{}{"value": v}
|
||||
if nf != "" {
|
||||
c["cell_styles"] = map[string]interface{}{"number_format": nf}
|
||||
}
|
||||
return c
|
||||
}
|
||||
col := func(cells ...map[string]interface{}) [][]map[string]interface{} {
|
||||
rows := make([][]map[string]interface{}, len(cells))
|
||||
for i, c := range cells {
|
||||
rows[i] = []map[string]interface{}{c}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
if typ, f := inferColumnType(col(mk(45306.0, "yyyy-mm-dd")), 0); typ != "date" || f != "yyyy-mm-dd" {
|
||||
t.Errorf("date col → %s/%s", typ, f)
|
||||
}
|
||||
if typ, f := inferColumnType(col(mk(100.0, "#,##0")), 0); typ != "number" || f != "#,##0" {
|
||||
t.Errorf("number col → %s/%s", typ, f)
|
||||
}
|
||||
if typ, _ := inferColumnType(col(mk(true, "")), 0); typ != "bool" {
|
||||
t.Errorf("bool col → %s", typ)
|
||||
}
|
||||
if typ, _ := inferColumnType(col(mk("x", "")), 0); typ != "string" {
|
||||
t.Errorf("string col → %s", typ)
|
||||
}
|
||||
// digit-like value carrying text format (@) infers as string, not number —
|
||||
// this is what makes +table-put's string columns (ids/postcodes) survive read-back.
|
||||
if typ, _ := inferColumnType(col(mk(123.0, "@")), 0); typ != "string" {
|
||||
t.Errorf("@-format numeric-looking col → %s, want string", typ)
|
||||
}
|
||||
if typ, _ := inferColumnType([][]map[string]interface{}{}, 0); typ != "string" {
|
||||
t.Errorf("empty col → %s (want string)", typ)
|
||||
}
|
||||
|
||||
// Mixed number+text degrades to string (self-consistent: every value is then
|
||||
// a string), so the column round-trips and pandas doesn't choke. Numeric
|
||||
// coercion of the dirty cells is left to the caller (pandas to_numeric).
|
||||
if typ, _ := inferColumnType(col(mk(100.0, ""), mk("暂无", ""), mk(200.0, "")), 0); typ != "string" {
|
||||
t.Errorf("mixed number+text col → %s, want string", typ)
|
||||
}
|
||||
// A bare number mixed into a date column must NOT stay date (would serial-
|
||||
// convert the number into a bogus date) — degrades to string.
|
||||
if typ, _ := inferColumnType(col(mk(45306.0, "yyyy-mm-dd"), mk(5.0, "")), 0); typ != "string" {
|
||||
t.Errorf("date+bare-number col → %s, want string", typ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableGet_CellToTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
mk := func(v interface{}) map[string]interface{} { return map[string]interface{}{"value": v} }
|
||||
if v := cellToTyped(mk(45306.0), "date"); v != "2024-01-15" {
|
||||
t.Errorf("date serial → %v, want 2024-01-15", v)
|
||||
}
|
||||
if v := cellToTyped(mk(100.0), "number"); v != 100.0 {
|
||||
t.Errorf("number → %v", v)
|
||||
}
|
||||
if v := cellToTyped(mk(true), "bool"); v != true {
|
||||
t.Errorf("bool → %v", v)
|
||||
}
|
||||
if v := cellToTyped(mk(""), "string"); v != nil {
|
||||
t.Errorf("empty string → %v, want nil", v)
|
||||
}
|
||||
if v := cellToTyped(nil, "string"); v != nil {
|
||||
t.Errorf("nil → %v, want nil", v)
|
||||
}
|
||||
if v := cellToTyped(mk("hi"), "string"); v != "hi" {
|
||||
t.Errorf("string → %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTableGet_DigitStringRoundTrip: a column +table-put wrote as string (text
|
||||
// format @) reads back as string, not number — so leading-zero ids / postcodes
|
||||
// survive instead of collapsing to a number.
|
||||
func TestTableGet_DigitStringRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
region := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
|
||||
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+
|
||||
`[{"value":"邮编"}],`+
|
||||
`[{"value":"00123","cell_styles":{"number_format":"@"}}]`+
|
||||
`]}]}`)
|
||||
out, err := runShortcutWithStubs(t, TableGet,
|
||||
[]string{"--url", testURL, "--sheet-name", "S"}, region, cells)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
sheets, _ := data["sheets"].([]interface{})
|
||||
s0, _ := sheets[0].(map[string]interface{})
|
||||
cols, _ := s0["columns"].([]interface{})
|
||||
if c0, _ := cols[0].(map[string]interface{}); c0["type"] != "string" {
|
||||
t.Errorf("@-format col 邮编 → type %v, want string", c0["type"])
|
||||
}
|
||||
rows, _ := s0["rows"].([]interface{})
|
||||
if r0, _ := rows[0].([]interface{}); r0[0] != "00123" {
|
||||
t.Errorf("value = %v, want \"00123\" (leading zero preserved)", r0[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTableGet_ExecuteRoundTrip reads a sheet back and checks the output is the
|
||||
// same typed protocol +table-put consumes: date serial → ISO, number preserved,
|
||||
// types inferred from number_format.
|
||||
func TestTableGet_ExecuteRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
region := toolOutputStub(testToken, "read", `{"current_region":"A1:C2"}`)
|
||||
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+
|
||||
`[{"value":"门店"},{"value":"月份"},{"value":"销售额"}],`+
|
||||
`[{"value":"北京"},{"value":45306,"cell_styles":{"number_format":"yyyy-mm"}},{"value":259874,"cell_styles":{"number_format":"#,##0"}}]`+
|
||||
`]}]}`)
|
||||
out, err := runShortcutWithStubs(t, TableGet,
|
||||
[]string{"--url", testURL, "--sheet-name", "销售"}, region, cells)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
sheets, _ := data["sheets"].([]interface{})
|
||||
if len(sheets) != 1 {
|
||||
t.Fatalf("want 1 sheet, got %d", len(sheets))
|
||||
}
|
||||
s0, _ := sheets[0].(map[string]interface{})
|
||||
if s0["name"] != "销售" {
|
||||
t.Errorf("name = %v, want 销售", s0["name"])
|
||||
}
|
||||
cols, _ := s0["columns"].([]interface{})
|
||||
if len(cols) != 3 {
|
||||
t.Fatalf("want 3 columns, got %d", len(cols))
|
||||
}
|
||||
c1, _ := cols[1].(map[string]interface{})
|
||||
if c1["name"] != "月份" || c1["type"] != "date" || c1["format"] != "yyyy-mm" {
|
||||
t.Errorf("col 月份 = %#v, want name=月份 date yyyy-mm", c1)
|
||||
}
|
||||
c2, _ := cols[2].(map[string]interface{})
|
||||
if c2["type"] != "number" || c2["format"] != "#,##0" {
|
||||
t.Errorf("col 销售额 = %#v, want number #,##0", c2)
|
||||
}
|
||||
rows, _ := s0["rows"].([]interface{})
|
||||
r0, _ := rows[0].([]interface{})
|
||||
if r0[1] != "2024-01-15" {
|
||||
t.Errorf("date roundtrip = %v, want 2024-01-15 (serial 45306 → ISO)", r0[1])
|
||||
}
|
||||
if r0[2] != float64(259874) {
|
||||
t.Errorf("number = %v, want 259874", r0[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableGet_DryRunIncludesCellRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, TableGet, []string{"--url", testURL, "--sheet-name", "S"})
|
||||
found := false
|
||||
for _, c := range calls {
|
||||
body, _ := c.(map[string]interface{})["body"].(map[string]interface{})
|
||||
if body == nil {
|
||||
continue
|
||||
}
|
||||
if tn, _ := body["tool_name"].(string); tn == "get_cell_ranges" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("dry-run should include a get_cell_ranges read")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTableGet_AllSheets covers the "read every sheet" path (no --sheet-name):
|
||||
// get_workbook_structure lists sheets, then each is read in order.
|
||||
func TestTableGet_AllSheets(t *testing.T) {
|
||||
t.Parallel()
|
||||
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"s1","sheet_name":"A","index":0},{"sheet_id":"s2","sheet_name":"B","index":1}]}`)
|
||||
regionA := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
|
||||
cellsA := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"项"}],[{"value":"x"}]]}]}`)
|
||||
regionB := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
|
||||
cellsB := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"项"}],[{"value":"y"}]]}]}`)
|
||||
out, err := runShortcutWithStubs(t, TableGet,
|
||||
[]string{"--url", testURL}, structure, regionA, cellsA, regionB, cellsB)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
sheets, _ := data["sheets"].([]interface{})
|
||||
if len(sheets) != 2 {
|
||||
t.Fatalf("want 2 sheets (all), got %d", len(sheets))
|
||||
}
|
||||
got := []string{
|
||||
sheets[0].(map[string]interface{})["name"].(string),
|
||||
sheets[1].(map[string]interface{})["name"].(string),
|
||||
}
|
||||
if got[0] != "A" || got[1] != "B" {
|
||||
t.Errorf("sheet names = %v, want [A B] in workbook order", got)
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
72
shortcuts/sheets/lark_sheet_workbook_export_test.go
Normal file
72
shortcuts/sheets/lark_sheet_workbook_export_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestWorkbookExport_ExecuteExportOnly covers the no-download path: without
|
||||
// --output-path, +workbook-export delegates to the shared drive export core
|
||||
// with OutputDir="" so it creates + polls the export task and returns the ready
|
||||
// file token without writing a local file (downloaded=false).
|
||||
func TestWorkbookExport_ExecuteExportOnly(t *testing.T) {
|
||||
stubs := []*httpmock.Stub{
|
||||
{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"ticket": "tk_export"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_export",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"result": map[string]interface{}{
|
||||
"job_status": float64(0),
|
||||
"file_token": "ftk_xlsx",
|
||||
"file_name": "report.xlsx",
|
||||
"file_size": float64(2048),
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := runShortcutWithStubs(t, WorkbookExport, []string{
|
||||
"--url", testURL, "--file-extension", "xlsx", "--as", "user",
|
||||
}, stubs...)
|
||||
if err != nil {
|
||||
t.Fatalf("export-only execute failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
idx := strings.Index(out, "{")
|
||||
if idx < 0 {
|
||||
t.Fatalf("no JSON envelope:\n%s", out)
|
||||
}
|
||||
var env struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
|
||||
}
|
||||
if env.Data["ready"] != true {
|
||||
t.Errorf("ready = %v, want true", env.Data["ready"])
|
||||
}
|
||||
if env.Data["downloaded"] != false {
|
||||
t.Errorf("downloaded = %v, want false (no --output-path)", env.Data["downloaded"])
|
||||
}
|
||||
if env.Data["file_token"] != "ftk_xlsx" {
|
||||
t.Errorf("file_token = %v, want ftk_xlsx", env.Data["file_token"])
|
||||
}
|
||||
if env.Data["doc_type"] != "sheet" {
|
||||
t.Errorf("doc_type = %v, want sheet", env.Data["doc_type"])
|
||||
}
|
||||
}
|
||||
135
shortcuts/sheets/lark_sheet_workbook_import_test.go
Normal file
135
shortcuts/sheets/lark_sheet_workbook_import_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
// chdirTemp switches into a fresh temp dir for the duration of the test and
|
||||
// restores the original cwd afterwards. +workbook-import is the first sheets
|
||||
// shortcut that stat()s a real local file, so these tests need a working dir.
|
||||
func chdirTemp(t *testing.T) {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(t.TempDir()); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(orig) })
|
||||
}
|
||||
|
||||
// TestWorkbookImport_DryRunPinsSheetType verifies the shortcut delegates to the
|
||||
// shared drive import core and hard-codes the import target type to "sheet".
|
||||
func TestWorkbookImport_DryRunPinsSheetType(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
calls := parseDryRunAPI(t, WorkbookImport, []string{"--file", "./data.xlsx"})
|
||||
|
||||
var createBody map[string]interface{}
|
||||
for _, c := range calls {
|
||||
cm, _ := c.(map[string]interface{})
|
||||
if u, _ := cm["url"].(string); u == "/open-apis/drive/v1/import_tasks" {
|
||||
createBody, _ = cm["body"].(map[string]interface{})
|
||||
}
|
||||
}
|
||||
if createBody == nil {
|
||||
t.Fatalf("no import_tasks create call in dry-run: %#v", calls)
|
||||
}
|
||||
if createBody["type"] != "sheet" {
|
||||
t.Errorf("import type = %v, want sheet (must be pinned regardless of file)", createBody["type"])
|
||||
}
|
||||
if createBody["file_extension"] != "xlsx" {
|
||||
t.Errorf("file_extension = %v, want xlsx", createBody["file_extension"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookImport_RejectsNonSheetFile ensures a file that cannot become a
|
||||
// spreadsheet (e.g. .docx) is rejected up front by the pinned-sheet validation.
|
||||
func TestWorkbookImport_RejectsNonSheetFile(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("notes.docx", []byte("fake-docx"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
// Validate runs before DryRun, so the pinned-sheet check rejects .docx up
|
||||
// front and the error surfaces through the normal envelope/err path.
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookImport, []string{"--file", "./notes.docx", "--dry-run"})
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "can only be imported") {
|
||||
t.Errorf("expected .docx → sheet type-mismatch rejection; got stdout=%s stderr=%s err=%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookImport_ExecuteCreatesSheet runs the full upload → create → poll
|
||||
// flow against stubs and asserts the resulting URL is a /sheets/ link.
|
||||
func TestWorkbookImport_ExecuteCreatesSheet(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
stubs := []*httpmock.Stub{
|
||||
{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"file_token": "file_import_media"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/import_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"ticket": "tk_sheet"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/tk_sheet",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"result": map[string]interface{}{
|
||||
"token": "shtcn_imported",
|
||||
"type": "sheet",
|
||||
"job_status": float64(0),
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := runShortcutWithStubs(t, WorkbookImport, []string{"--file", "./data.csv", "--as", "user"}, stubs...)
|
||||
if err != nil {
|
||||
t.Fatalf("import execute failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
idx := strings.Index(out, "{")
|
||||
if idx < 0 {
|
||||
t.Fatalf("execute output has no JSON envelope:\n%s", out)
|
||||
}
|
||||
var env struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
|
||||
}
|
||||
if url, _ := env.Data["url"].(string); !strings.Contains(url, "/sheets/") {
|
||||
t.Errorf("imported url = %q, want a /sheets/ link", url)
|
||||
}
|
||||
if tok, _ := env.Data["token"].(string); tok != "shtcn_imported" {
|
||||
t.Errorf("token = %q, want shtcn_imported", tok)
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,28 @@ func TestWorkbookShortcuts_DryRun(t *testing.T) {
|
||||
"tab_color": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-show-gridline",
|
||||
sc: SheetShowGridline,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "show_gridline",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-hide-gridline",
|
||||
sc: SheetHideGridline,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "hide_gridline",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -339,21 +361,21 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookExport_DryRun checks the 2-or-3 step plan depending on
|
||||
// --output-path. The order should be: POST → GET (poll) → optional GET
|
||||
// (download).
|
||||
// TestWorkbookExport_DryRun verifies the export dry-run now delegates to the
|
||||
// shared drive export core: a single create-task POST (poll + download are
|
||||
// described inline rather than as separate api entries).
|
||||
func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) {
|
||||
t.Run("xlsx create-task body pins type=sheet", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls))
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1 (create export task)", len(calls))
|
||||
}
|
||||
create := calls[0].(map[string]interface{})
|
||||
if create["url"] != "/open-apis/drive/v1/export_tasks" {
|
||||
t.Errorf("first url = %v", create["url"])
|
||||
t.Errorf("url = %v", create["url"])
|
||||
}
|
||||
body, _ := create["body"].(map[string]interface{})
|
||||
if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken {
|
||||
@@ -361,22 +383,18 @@ func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csv → 3 steps, with sub_id", func(t *testing.T) {
|
||||
t.Run("csv includes sub_id from --sheet-id", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookExport, []string{
|
||||
"--url", testURL, "--file-extension", "csv", "--sheet-id", "sh1",
|
||||
"--output-path", "/tmp/out.csv",
|
||||
})
|
||||
if len(calls) != 3 {
|
||||
t.Fatalf("api calls = %d, want 3", len(calls))
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1", len(calls))
|
||||
}
|
||||
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
|
||||
if body["sub_id"] != "sh1" {
|
||||
t.Errorf("csv export missing sub_id: %#v", body)
|
||||
}
|
||||
dl := calls[2].(map[string]interface{})
|
||||
if !strings.Contains(dl["url"].(string), "/export_tasks/file/") {
|
||||
t.Errorf("download url = %v", dl["url"])
|
||||
if body["type"] != "sheet" || body["sub_id"] != "sh1" {
|
||||
t.Errorf("csv export body = %#v (want type=sheet, sub_id=sh1)", body)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,否则照样报上面的错。
|
||||
|
||||
@@ -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` 让纵轴变 %,并把数据源指向占比列 / 让数据标签显示百分比;不要交付纵轴仍是原始计数的图(反例:要求看各类占比,却用普通堆积柱、纵轴是 0–350 的人数而非百分比)。
|
||||
- **创建后必须验证**:图表创建后必须调用 `+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 20,after 即从 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 号店"那一段(第 11–17 行)。
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
@@ -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` 原生工具**,不要"读出来本地排完再整列写回",从根上避免这类回写污染。
|
||||
|
||||
## 公式策略
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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、Equity;Cash、AR、Inventory、Prepaids 属 Current Assets,PP&E / Goodwill / Intangibles 属 Non-current Assets,AP / Accrued / ST Debt / Deferred Revenue 属 Current Liabilities,LT 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 引用 Assumptions,Output 引用 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`。
|
||||
@@ -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` 识别已有合并区域(特征:多个连续单元格中只有左上角有值,其余为空)。
|
||||
|
||||
@@ -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`)。
|
||||
|
||||
41
skills/lark-sheets/references/lark-sheets-recover.md
Normal file
41
skills/lark-sheets/references/lark-sheets-recover.md
Normal 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`。
|
||||
@@ -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}` 章节。
|
||||
|
||||
|
||||
@@ -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_styles);row/col sizes 用行/列范围 + type/size;merges 用单元格 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:C,type 为 pixel/standard,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
- `name` (string) — 子表名
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 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`。
|
||||
|
||||
@@ -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. **量纲 / 单位换算 / 数量乘项预检(高频致命错误,公式不报错但结果整体偏倍数)**:从文本提取数字做计算前,先核对**单位是否统一、是否漏乘数量、口径是否一致**——这类错误公式能跑通、无 `#` 报错,回读也看不出(值"像对的")。必须用本地脚本对 3–5 个代表行**离线手算一遍预期值**,与公式结果逐格比对量级:① 单位不一致先统一再算(典型反例:尺寸 `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 解析;防爆参数上限校验。
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user