Files
larksuite__cli/shortcuts/sheets/range_sheet_prefix.go
xiongyuanwen-byted c6c040c2c5 refactor(sheets)!: remove legacy sheets command surface (#2572)
* 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).
2026-09-01 19:47:18 +08:00

226 lines
8.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}