mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
c6c040c2c5
* refactor(sheets)!: remove legacy sheets command surface
The `shortcuts/sheets/backward` package kept 42 pre-refactor command names
(`+create`, `+read`, `+write`, `+create-sheet`, `+media-upload`, ...) alive
alongside the refactored ones. Monitoring puts their combined share below 5%,
so they are dropped along with the machinery that carried them.
Removed with the package:
- the `sheetsAliasReplacement` map and `wrapSheetsBackwardDeprecation`, which
tagged each alias with a `_notice` deprecation envelope on execution;
- the deprecated cobra group and the custom `sheets --help` usage template that
existed only to hide it. `applySheetsCompatGroups` becomes
`applySheetsCommandGroups`: it still groups the `+`-shortcuts so the OpenAPI
metaapi subcommands keep filing under cobra's stock "Additional Commands".
The deleted package owned no shared logic. Its `parent_type` mapping for image
uploads was, by its own header, a deliberate mirror of the canonical one in
`shortcuts/sheets/helpers.go`; `common.IsLocalOfficeToken` and the drive upload
helpers are untouched and keep their other callers.
E2E tests still drove the removed commands and are ported to the refactored
surface: `+workbook-create` / `+workbook-info` / `+cells-set` / `+cells-get` /
`+cells-search` / `+sheet-*`. The sub-sheet dry-run assertions had to be
rewritten rather than renamed, because the old commands posted to
`sheets/v2/sheets_batch_update` while the new ones invoke
`modify_workbook_structure` over `sheet_ai/v2`. `+update-sheet` fanned out to
`+sheet-rename` + `+sheet-hide` + `+dim-freeze`. Every migrated command was
verified against a live workbook, which is where the assertions come from:
rename and hide answer with a bare revision counter, so their effect is read
back from `+workbook-info` (`sheet_name`, `is_hidden`).
Also updated, since these referenced the removed surface:
- three `skills/lark-drive` reference docs that instructed agents to run
`sheets +read` / `sheets +find`; these ship embedded in the binary, so the
instructions would have produced unknown-subcommand errors;
- `skill-template/domains/sheets.md`, deleted: every sheets command it named
was removed and the cell payload shape it taught
(`{"type":"formula","text":...}`) is rejected by `+cells-set`;
- stale comments naming `backward.uploadSheetMediaFile` and
`backward/helpers.go`.
`Shortcut.OnInvoke` and `internal/deprecation` now have no producers. Both are
generic framework plumbing wired into the `_notice` envelope in `cmd/root.go`,
so they are left in place; the `OnInvoke` doc comment no longer claims a caller.
BREAKING CHANGE: removes the 42 pre-refactor sheets commands (`+create`,
`+read`, `+write`, `+append`, `+find`, `+set-style`, `+create-sheet`,
`+update-sheet`, `+add-dimension`, `+set-dropdown`, `+media-upload`,
`+create-filter-view`, ...). They now fail with `unknown subcommand` and carry
no deprecation notice, so a caller still on the old names gets no migration
pointer at runtime.
Replacements for all 42, plus the differences that are not simple renames — the
cell payload vocabulary (`{"type":"formula","text":...}` is now rejected),
response field paths, and `+update-sheet` / `+update-dimension` fanning out to
several commands — are documented in
skills/lark-sheets/references/lark-sheets-legacy-command-migration.md, reachable
at runtime via:
lark-cli skills read lark-sheets references/lark-sheets-legacy-command-migration.md
* test(sheets): close the assertion gaps found in review
- The append subtest asserted only the ok envelope, so a +cells-set that
reported success without persisting would pass; the later +cells-search
covers row 2 only. Read A4:C4 back and assert the row landed. Verified
non-vacuous against a live sheet: an unwritten row returns cells carrying
no value, so the read-back fails if the write does not persist.
- Cover the omitted-title +sheet-copy path. The comment on the empty-title
suite states an omitted --title means "let the server name the copy", but
nothing exercised it; the new case pins that new_name is absent from the
payload rather than sent empty.
- Assert tool_name in the shared dry-run loop instead of only in the create
case, so copy / delete / rename / move cannot pass by selecting a different
tool on the same /tools/invoke_write endpoint.
* docs(sheets): fix the migration guide examples found in review
- `+table-put --sheets` requires the `{"sheets":[…]}` envelope; the `+append`
row showed a bare array, which the flag rejects outright ("top level must be
the object {\"sheets\":[…]}, got a bare JSON array"). Verified both forms
against a dry-run before and after.
- Tag the diagnostic fence as `text` (markdownlint MD040).
226 lines
8.1 KiB
Go
226 lines
8.1 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package sheets
|
||
|
||
import (
|
||
"strings"
|
||
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
// ─── sheet prefix inside --range ────────────────────────────────────────
|
||
//
|
||
// Eval traces: 707 calls died on "specify at least one of --sheet-id or
|
||
// --sheet-name", 53% of them with the sheet already named inside --range
|
||
// (`--range "Sheet1!A1:D20"`). The prefix is read as the selector and the bare
|
||
// A1 part reaches the tool — same tier as the silent aliases in
|
||
// flag_ergonomics.go, the intent being unambiguous.
|
||
//
|
||
// Two deliberate limits:
|
||
//
|
||
// - Only the no-selector case is rewritten. An explicit --sheet-id /
|
||
// --sheet-name stays authoritative and --range passes through untouched,
|
||
// so a disagreeing prefix can never silently retarget a write.
|
||
// - Only --range. +range-copy / +range-move / +range-fill name their
|
||
// destination with --target-sheet-id, so a prefix on --source-range /
|
||
// --target-range need not mean the sheet the selector picks.
|
||
//
|
||
// It lands in --sheet-name because `Sheet1!A1` is the Excel / Lark-formula
|
||
// spelling, where the prefix is a name. A prefix that is really an id hits the
|
||
// tool's "sheet not found", which lists every {id, name} pair.
|
||
|
||
const rangeSheetPrefixFlag = "range"
|
||
|
||
// rangeSheetPrefixApplies reports whether a command carries the trio the
|
||
// rewrite needs: a plain-string --range plus the --sheet-id / --sheet-name
|
||
// pair. Commands outside it are untouched — +formula-verify takes repeated
|
||
// --range values, +pivot-create's selector is the placement target
|
||
// (--target-sheet-*), and the fan-out shortcuts locate every range inside
|
||
// --ranges.
|
||
func rangeSheetPrefixApplies(command string) bool {
|
||
defs, err := loadFlagDefs()
|
||
if err != nil {
|
||
return false
|
||
}
|
||
spec, ok := defs[command]
|
||
if !ok {
|
||
return false
|
||
}
|
||
var hasRange, hasID, hasName bool
|
||
for _, df := range spec.Flags {
|
||
switch df.Name {
|
||
case rangeSheetPrefixFlag:
|
||
hasRange = df.Type == "string"
|
||
case "sheet-id":
|
||
hasID = true
|
||
case "sheet-name":
|
||
hasName = true
|
||
}
|
||
}
|
||
return hasRange && hasID && hasName
|
||
}
|
||
|
||
// splitRangeSheetPrefix splits "Sheet1!A1:D20" into ("Sheet1", "A1:D20"),
|
||
// following the front-end ref lexer so a reference copied out of a formula or
|
||
// a sheet UI parses here the way it does there:
|
||
//
|
||
// - The separator has four equal spellings: "!", the full-width "!"
|
||
// (TractorLexer.ts, ExclamationMark = `[ \t\r\n]*(?:!|!)[ \t\r\n]*`), and
|
||
// the backslash-escaped forms of both, which survive shell history
|
||
// expansion. The pre-refactor v2 surface normalized the same four
|
||
// spellings, so nothing a caller could already type is rejected here.
|
||
// - Quoted names ('My Sheet'!A1) are unwrapped, doubled-quote escape
|
||
// collapsed. The quotes are what delimit the name, so one may contain a
|
||
// "!"; escapeSheetName quotes everything that is not pure a-z, so any name
|
||
// with a space, a digit or CJK arrives in this form.
|
||
// - Unquoted names split on the first separator — the lexer's Identifier
|
||
// production excludes "!" at both widths, so a name cannot contain one.
|
||
//
|
||
// One deliberate divergence: the lexer also bars whitespace from an unquoted
|
||
// name so that `SUM(My Sheet!A1)` tokenizes; a flag has no such ambiguity, so
|
||
// `My Sheet!A1` is accepted rather than rejected over missing quotes.
|
||
//
|
||
// ok is false with no separator, an empty side ("!A1", "Sheet1!"), or an
|
||
// unclosed quote — malformed rather than prefixed, and the flag's own
|
||
// validation names those better than a half-applied rewrite would.
|
||
func splitRangeSheetPrefix(rng string) (sheet, rest string, ok bool) {
|
||
rng = strings.TrimSpace(rng)
|
||
sheet, end, ok := scanSheetQualifier(rng)
|
||
if !ok {
|
||
return "", "", false
|
||
}
|
||
rest = strings.TrimSpace(rng[end:])
|
||
if sheet == "" || rest == "" {
|
||
return "", "", false
|
||
}
|
||
return sheet, rest, true
|
||
}
|
||
|
||
// scanSheetQualifier is the grammar itself. It returns the sheet the qualifier
|
||
// names (unquoted, escapes collapsed) and the byte offset just past its
|
||
// separator, so rng[:end] is the verbatim qualifier and rng[end:] is the A1
|
||
// part. rng is expected pre-trimmed.
|
||
//
|
||
// The offset is what callers re-rendering a range need: parseCellRange's output
|
||
// is both shipped to the server and printed for the caller to paste back, so
|
||
// the qualifier has to survive EXACTLY as written — full-width separator,
|
||
// quotes and all — which a parsed-and-reassembled name cannot promise.
|
||
//
|
||
// ok is false with no qualifier at all or an unclosed quote. An empty side is
|
||
// the caller's to judge: "!A1" is malformed to the selector rewrite but has
|
||
// always been tolerated by the dimension parser.
|
||
func scanSheetQualifier(rng string) (sheet string, end int, ok bool) {
|
||
if strings.HasPrefix(rng, "'") {
|
||
return scanQuotedSheetQualifier(rng)
|
||
}
|
||
// The lexer's unquoted name production (Identifier) excludes both widths
|
||
// of the separator, so the first one is necessarily the boundary.
|
||
for i, r := range rng {
|
||
if r != '!' && r != '!' {
|
||
continue
|
||
}
|
||
nameEnd := i
|
||
// A backslash immediately before is part of the separator's spelling,
|
||
// not of the name: `Sheet1\!A1` survives shell history expansion.
|
||
if i > 0 && rng[i-1] == '\\' {
|
||
nameEnd = i - 1
|
||
}
|
||
return strings.TrimSpace(rng[:nameEnd]), i + len(string(r)), true
|
||
}
|
||
return "", 0, false
|
||
}
|
||
|
||
// scanQuotedSheetQualifier handles the 'Sheet name'!A1 form. Scanning byte by
|
||
// byte is safe for multi-byte names: only the ASCII quote is compared, and
|
||
// every other byte is copied through verbatim. Since the quotes are what
|
||
// delimit the name, one may contain a separator.
|
||
func scanQuotedSheetQualifier(rng string) (sheet string, end int, ok bool) {
|
||
var name strings.Builder
|
||
for i := 1; i < len(rng); i++ {
|
||
if rng[i] != '\'' {
|
||
name.WriteByte(rng[i])
|
||
continue
|
||
}
|
||
if i+1 < len(rng) && rng[i+1] == '\'' {
|
||
// A doubled quote is one literal quote, not the terminator.
|
||
name.WriteByte('\'')
|
||
i++
|
||
continue
|
||
}
|
||
sepStart := i + 1
|
||
tail := rng[sepStart:]
|
||
ws := len(tail) - len(strings.TrimLeft(tail, " \t\r\n"))
|
||
tail = tail[ws:]
|
||
sepLen := ws
|
||
if strings.HasPrefix(tail, `\`) {
|
||
tail, sepLen = tail[1:], sepLen+1
|
||
}
|
||
switch {
|
||
case strings.HasPrefix(tail, "!"):
|
||
sepLen++
|
||
case strings.HasPrefix(tail, "!"):
|
||
sepLen += len("!")
|
||
default:
|
||
return "", 0, false
|
||
}
|
||
return strings.TrimSpace(name.String()), sepStart + sepLen, true
|
||
}
|
||
return "", 0, false
|
||
}
|
||
|
||
// chainRangeSheetPrefix installs a PreRunE stage (composed onto any prior
|
||
// PreRunE, which runs first) that moves a sheet prefix found in --range into
|
||
// --sheet-name and leaves the bare A1 range behind. cobra runs PreRunE before
|
||
// ValidateRequiredFlags / ValidateFlagGroups, and every later reader — the
|
||
// shortcut's Validate, DryRun and Execute — reads the flag set live, so the
|
||
// completed pair is what the whole call sees.
|
||
func chainRangeSheetPrefix(cmd *cobra.Command) {
|
||
if !rangeSheetPrefixApplies(cmd.Name()) {
|
||
return
|
||
}
|
||
prev := cmd.PreRunE
|
||
cmd.PreRunE = func(c *cobra.Command, args []string) error {
|
||
if prev != nil {
|
||
if err := prev(c, args); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// --print-schema is pure local introspection; leave the flags alone.
|
||
if want, err := c.Flags().GetBool("print-schema"); err == nil && want {
|
||
return nil
|
||
}
|
||
applyRangeSheetPrefixToFlags(c)
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// applyRangeSheetPrefixToFlags performs the rewrite on a parsed flag set.
|
||
// Every failed read is a silent no-op: the goal is to complete a call that
|
||
// would otherwise fail, never to invent a second failure mode.
|
||
func applyRangeSheetPrefixToFlags(c *cobra.Command) {
|
||
rng, err := c.Flags().GetString(rangeSheetPrefixFlag)
|
||
if err != nil || strings.TrimSpace(rng) == "" {
|
||
return
|
||
}
|
||
sheetID, err := c.Flags().GetString("sheet-id")
|
||
if err != nil {
|
||
return
|
||
}
|
||
sheetName, err := c.Flags().GetString("sheet-name")
|
||
if err != nil {
|
||
return
|
||
}
|
||
if strings.TrimSpace(sheetID) != "" || strings.TrimSpace(sheetName) != "" {
|
||
return
|
||
}
|
||
sheet, rest, ok := splitRangeSheetPrefix(rng)
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := c.Flags().Set("sheet-name", sheet); err != nil {
|
||
return
|
||
}
|
||
_ = c.Flags().Set(rangeSheetPrefixFlag, rest)
|
||
}
|