Merge pull request #1130 from zhengzhijiej-tech/fix/pivot-create-schema-alignment

fix(sheets): allow +pivot-create to omit both sheet selectors
This commit is contained in:
zhengzhijiej-tech
2026-05-27 17:01:46 +08:00
committed by GitHub
5 changed files with 181 additions and 33 deletions

View File

@@ -3244,14 +3244,14 @@
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
"desc": "Reference_id of the sub-sheet where the pivot table is located / will be created to (mutually exclusive with --sheet-name; takes priority when both given; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
"desc": "Name of the sub-sheet where the pivot table is located / will be created to (mutually exclusive with --sheet-id; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended)"
},
{
"name": "properties",
@@ -3264,13 +3264,6 @@
"stdin"
]
},
{
"name": "target-sheet-id",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Destination sub-sheet id for the pivot table; auto-creates a new sub-sheet when omitted (recommended)"
},
{
"name": "target-position",
"kind": "own",

View File

@@ -133,6 +133,33 @@ func requireSheetSelector(sheetID, sheetName string) error {
return nil
}
// optionalSheetSelector is the "at most one" counterpart of
// requireSheetSelector: both empty is acceptable (the backend tool then
// decides what to do — e.g. manage_pivot_table_object auto-creates a new
// sub-sheet to host the pivot), and both set is rejected. Control-char
// validation still applies whenever a value is provided.
//
// Used by shortcuts whose backend tool treats sheet_id/sheet_name as the
// placement target rather than the operation context (currently only
// +pivot-create). Other shortcuts continue to use requireSheetSelector.
func optionalSheetSelector(sheetID, sheetName string) error {
sheetID = strings.TrimSpace(sheetID)
sheetName = strings.TrimSpace(sheetName)
if sheetID != "" && sheetName != "" {
return common.FlagErrorf("--sheet-id and --sheet-name are mutually exclusive")
}
if sheetID != "" {
if err := validate.RejectControlChars(sheetID, "sheet-id"); err != nil {
return common.FlagErrorf("%v", err)
}
} else if sheetName != "" {
if err := validate.RejectControlChars(sheetName, "sheet-name"); err != nil {
return common.FlagErrorf("%v", err)
}
}
return nil
}
// sheetSelectorForToolInput packs --sheet-id / --sheet-name into the tool
// input map, omitting empty fields. Use after resolveSheetSelector returns.
func sheetSelectorForToolInput(input map[string]interface{}, sheetID, sheetName string) {

View File

@@ -27,10 +27,11 @@ import (
// the surface narrow even though everything funnels through one tool).
//
// Five of the seven objects share the factory below (newObjectCRUDShortcuts).
// pivot adds optional --target-sheet-id / --target-position on create,
// declared with extraCreateFlags. filter is special-cased further down
// (no separate id flag — filter_id is implicit per sheet — and --range is
// a first-class create flag, not buried in --data).
// pivot opts into allowEmptySheetSelectorOnCreate=true so the backend can
// auto-create a placement sub-sheet when neither --sheet-id nor --sheet-name
// is given; it also exposes optional --target-position on create. filter is
// special-cased further down (no separate id flag — filter_id is implicit
// per sheet — and --range is a first-class create flag, not buried in --data).
// objectCRUDSpec describes a 3-shortcut create/update/delete cluster.
// idFlag / idField empty → no per-object id flag (only filter uses that
@@ -54,6 +55,13 @@ type objectCRUDSpec struct {
// +sparkline-list instead of letting the caller hit an opaque
// server-side rejection).
validateUpdateInput func(input map[string]interface{}) error
// allowEmptySheetSelectorOnCreate, when true, makes the *create*
// shortcut accept empty --sheet-id / --sheet-name (backend then picks
// the placement target — e.g. manage_pivot_table_object auto-creates
// a sub-sheet to host the pivot). Both flags being set is still
// rejected. Update/delete continue to require an explicit selector.
// Today only pivotSpec opts in.
allowEmptySheetSelectorOnCreate bool
}
func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut {
@@ -79,7 +87,8 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut {
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
input, _ := objectCreateInput(runtime, token, sheetID, sheetName, spec)
return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input)
},
@@ -88,10 +97,8 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut {
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
input, err := objectCreateInput(runtime, token, sheetID, sheetName, spec)
if err != nil {
return err
@@ -107,7 +114,13 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut {
}
func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
var err error
if spec.allowEmptySheetSelectorOnCreate {
err = optionalSheetSelector(sheetID, sheetName)
} else {
err = requireSheetSelector(sheetID, sheetName)
}
if err != nil {
return nil, err
}
props, err := requireJSONObject(runtime, "properties")
@@ -288,17 +301,18 @@ var ChartCreate = newObjectCreateShortcut(chartSpec)
var ChartUpdate = newObjectUpdateShortcut(chartSpec)
var ChartDelete = newObjectDeleteShortcut(chartSpec)
// pivot — create exposes --target-sheet-id / --target-position (top-level
// of the tool input) plus --source / --range hoisted from properties.
// pivot — create exposes --target-position (top-level of the tool input)
// plus --source / --range hoisted from properties. --sheet-id / --sheet-name
// are the placement target (where the pivot table lands); the backend
// auto-creates a new sub-sheet when both are omitted, so create opts into
// allowEmptySheetSelectorOnCreate.
var pivotSpec = objectCRUDSpec{
commandPrefix: "+pivot",
toolName: "manage_pivot_table_object",
idFlag: "pivot-table-id",
idField: "pivot_table_id",
commandPrefix: "+pivot",
toolName: "manage_pivot_table_object",
idFlag: "pivot-table-id",
idField: "pivot_table_id",
allowEmptySheetSelectorOnCreate: true,
enhanceCreateInput: func(rt flagView, input map[string]interface{}) {
if v := strings.TrimSpace(rt.Str("target-sheet-id")); v != "" {
input["target_sheet_id"] = v
}
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
input["target_position"] = v
}

View File

@@ -51,16 +51,20 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
"properties": map[string]interface{}{"type": "bar"},
},
},
// pivot — has extra create flags incl. required --source
// pivot — has extra create flags incl. required --source.
// --sheet-id is the placement target (where the pivot lands);
// pivotSpec.allowEmptySheetSelectorOnCreate lets both --sheet-id
// and --sheet-name be omitted so the backend auto-creates a
// sub-sheet — covered separately in the +pivot-create empty-
// selector / mutex tests below.
{
name: "+pivot-create with target / source / range flags",
name: "+pivot-create with placement / source / range flags",
sc: PivotCreate,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
"--range", "F1",
"--target-sheet-id", "sh2",
"--target-position", "B5",
},
toolName: "manage_pivot_table_object",
@@ -68,7 +72,6 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "create",
"target_sheet_id": "sh2",
"target_position": "B5",
"properties": map[string]interface{}{
"rows": []interface{}{map[string]interface{}{"field": "A"}},
@@ -77,6 +80,26 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
},
},
},
// +pivot-create accepts both sheet selectors empty — backend
// auto-creates a placement sub-sheet.
{
name: "+pivot-create empty --sheet-id / --sheet-name omits sheet from input",
sc: PivotCreate,
args: []string{
"--url", testURL,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
},
toolName: "manage_pivot_table_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "create",
"properties": map[string]interface{}{
"rows": []interface{}{map[string]interface{}{"field": "A"}},
"source": "Sheet1!A1:F1000",
},
},
},
{
name: "+pivot-delete",
sc: PivotDelete,
@@ -325,6 +348,98 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
}
}
// TestPivotCreate_SheetSelectorSemantics locks in the "at most one"
// semantics for +pivot-create (and only +pivot-create): both --sheet-id
// and --sheet-name may be omitted (backend auto-creates a placement
// sub-sheet), but passing both is rejected.
//
// Companion regression — TestObjectCreate_RequiresSheetSelector below —
// confirms every other *-create still rejects empty selector.
func TestPivotCreate_SheetSelectorSemantics(t *testing.T) {
t.Parallel()
t.Run("both empty is accepted", func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, PivotCreate, []string{
"--url", testURL,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
})
input := decodeToolInput(t, body, "manage_pivot_table_object")
if _, ok := input["sheet_id"]; ok {
t.Errorf("expected no sheet_id in input; got %v", input["sheet_id"])
}
if _, ok := input["sheet_name"]; ok {
t.Errorf("expected no sheet_name in input; got %v", input["sheet_name"])
}
})
t.Run("both set is rejected", func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--sheet-name", "Sheet1",
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
})
if err == nil {
t.Fatalf("expected CLI to reject both --sheet-id and --sheet-name set; stderr=%s", stderr)
}
combined := stderr + err.Error()
if !strings.Contains(combined, "mutually exclusive") {
t.Errorf("expected error to say 'mutually exclusive'; got=%s|%v", stderr, err)
}
})
t.Run("only sheet-id is accepted", func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, PivotCreate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
})
input := decodeToolInput(t, body, "manage_pivot_table_object")
if got, _ := input["sheet_id"].(string); got != testSheetID {
t.Errorf("sheet_id = %q, want %q", got, testSheetID)
}
})
}
// TestObjectCreate_RequiresSheetSelector regresses the non-pivot create
// shortcuts: pivot-create is the only one whose spec sets
// allowEmptySheetSelectorOnCreate=true. Every other *-create must still
// reject empty --sheet-id / --sheet-name (this is the guardrail that
// keeps the change minimally scoped).
func TestObjectCreate_RequiresSheetSelector(t *testing.T) {
t.Parallel()
cases := []struct {
name string
sc common.Shortcut
args []string // omit sheet selector flags on purpose
}{
{"chart", ChartCreate, []string{"--url", testURL, "--properties", `{"type":"line"}`}},
{"cond-format", CondFormatCreate, []string{"--url", testURL, "--properties", `{"attrs":[]}`, "--rule-type", "cellIs", "--ranges", `["A1:A10"]`}},
{"sparkline", SparklineCreate, []string{"--url", testURL, "--properties", `{"sparklines":[]}`}},
{"filter-view", FilterViewCreate, []string{"--url", testURL, "--properties", `{}`, "--range", "A1:F10"}},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
if err == nil {
t.Fatalf("expected CLI to reject empty sheet selector for +%s-create; stderr=%s", tt.name, stderr)
}
combined := stderr + err.Error()
if !strings.Contains(combined, "specify at least one of --sheet-id or --sheet-name") {
t.Errorf("expected 'specify at least one of --sheet-id or --sheet-name'; got=%s|%v", stderr, err)
}
})
}
}
// TestSparklineUpdate_MissingSparklineID confirms the standalone-path
// pre-check fires: +sparkline-update with properties.sparklines[] but no
// per-item sparkline_id must fail CLI-side with a pointer to

View File

@@ -62,7 +62,6 @@ _公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--properties` | string + File + Stdin复合 JSON | required | JSON{"rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}(数据源走 --source不要再放进 properties.source |
| `--target-sheet-id` | string | optional | 透视表落点子表 id省略时自动新建子表推荐 |
| `--target-position` | string | optional | 透视表落点子表内的起始 cellA1 格式,如 `A1`),与 `--target-sheet-id` 配套、映射到顶层 `target_position`,默认 `A1`(值为 A1 时不下发)。它与 `--range` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 |
| `--source` | string | required | 透视表源数据区域A1 表示法,格式 `SheetName!StartCell:EndCell`,如 `Sheet1!A1:D100` |
| `--range` | string | optional | 透视表左上角放置位置A1 单值,如 `F1`,仅 create 生效),映射到 `properties.range`;省略时放在落点子表(默认新建子表)的左上角。它与 `--target-position` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 |