Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65a7280805 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
|
||||
@@ -1,70 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSheetFloatImageLocalFileFinalSchema(t *testing.T) {
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
|
||||
"sheet.create_float_image",
|
||||
"sheet.update_float_image",
|
||||
)
|
||||
|
||||
create := payload.Tools["sheet.create_float_image"]
|
||||
if create == nil {
|
||||
t.Fatal("missing sheet.create_float_image")
|
||||
}
|
||||
if create["interface_mode"] != "mcp" {
|
||||
t.Fatalf("create interface mode = %#v", create["interface_mode"])
|
||||
}
|
||||
createRef, _ := create["interface_ref"].(map[string]any)
|
||||
if createRef["product_id"] != "sheet" || createRef["rpc_name"] != "create_float_image" {
|
||||
t.Fatalf("create interface ref = %#v", createRef)
|
||||
}
|
||||
createDryRun, _ := create["dry_run"].(map[string]any)
|
||||
if createDryRun["preview_kind"] != "request" {
|
||||
t.Fatalf("create dry-run = %#v", createDryRun)
|
||||
}
|
||||
if remoteReads, exists := createDryRun["remote_reads"]; exists && remoteReads != false {
|
||||
t.Fatalf("create dry-run remote_reads = %#v", remoteReads)
|
||||
}
|
||||
createParameters, _ := create["parameters"].(map[string]any)
|
||||
file, _ := createParameters["file"].(map[string]any)
|
||||
src, _ := createParameters["src"].(map[string]any)
|
||||
if file["required"] != false || file["required_when"] != "exactly one of --file or --src must be provided" {
|
||||
t.Fatalf("create --file metadata = %#v", file)
|
||||
}
|
||||
if schemaContractString(file["property"]) != "" {
|
||||
t.Fatalf("create --file leaked an RPC property: %#v", file["property"])
|
||||
}
|
||||
if src["required"] != false || schemaContractString(src["required_when"]) != "" || src["property"] != "src" {
|
||||
t.Fatalf("create --src compatibility metadata = %#v", src)
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, create, "mutually_exclusive", []string{"file", "src"})
|
||||
assertSchemaContractConstraintGroup(t, create, "require_one_of", []string{"file", "src"})
|
||||
|
||||
update := payload.Tools["sheet.update_float_image"]
|
||||
if update == nil {
|
||||
t.Fatal("missing sheet.update_float_image")
|
||||
}
|
||||
updateDryRun, _ := update["dry_run"].(map[string]any)
|
||||
if update["interface_mode"] != "mcp" || updateDryRun["preview_kind"] != "request" {
|
||||
t.Fatalf("update interface/dry-run = %#v/%#v", update["interface_mode"], updateDryRun)
|
||||
}
|
||||
updateParameters, _ := update["parameters"].(map[string]any)
|
||||
updateFile, _ := updateParameters["file"].(map[string]any)
|
||||
if schemaContractString(updateFile["property"]) != "" {
|
||||
t.Fatalf("update --file leaked an RPC property: %#v", updateParameters["file"])
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, update, "mutually_exclusive", []string{"file", "src"})
|
||||
assertSchemaContractConstraintGroup(t, update, "require_one_of", []string{"file", "src", "range", "width", "height", "offset-x", "offset-y"})
|
||||
|
||||
root := NewRootCommand()
|
||||
for _, cliPath := range []string{"sheet create-float-image", "sheet update-float-image"} {
|
||||
command := exactCommandForTest(root, cliPath)
|
||||
if command == nil || command.Flags().Lookup("file") == nil {
|
||||
t.Fatalf("%s has no executable --file flag", cliPath)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -96,14 +96,7 @@ func init() {
|
||||
registerRequireOneOf("sheet.update_cond_format", "ranges", "condition", "cell-style", "data-bar-style")
|
||||
registerRequireOneOf("sheet.update_dimension", "hidden", "pixel-size")
|
||||
registerRequireOneOf("sheet.update_filter_view", "name", "range", "criteria")
|
||||
RegisterRuntimeSchemaConstraints("sheet.create_float_image", RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"file", "src"}},
|
||||
RequireOneOf: [][]string{{"file", "src"}},
|
||||
})
|
||||
RegisterRuntimeSchemaConstraints("sheet.update_float_image", RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"file", "src"}},
|
||||
RequireOneOf: [][]string{{"file", "src", "range", "width", "height", "offset-x", "offset-y"}},
|
||||
})
|
||||
registerRequireOneOf("sheet.update_float_image", "src", "range", "width", "height", "offset-x", "offset-y")
|
||||
registerRequireOneOf("sheet.update_sheet", "name", "index", "hidden", "frozen-row-count", "frozen-column-count", "tab-color")
|
||||
registerRequireOneOf("sheet.import", "folder-token", "workspace")
|
||||
registerRequireOneOf("wiki.search_wikiSpaces", "query", "type")
|
||||
|
||||
@@ -554,7 +554,6 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.chart_update --properties": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.chart_update --sheet-id": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
|
||||
"sheet.create_float_image --file": "local Sheet upload input used to obtain a resourceUrl before create_float_image",
|
||||
"sheet.create_pivot_table --properties": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_pivot_table --source": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --source is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_pivot_table --target-position": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-position is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -647,7 +646,6 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.ungroup_dimension --range": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.ungroup_dimension --sheet-id": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
|
||||
"sheet.update_float_image --file": "local Sheet upload input used to obtain a resourceUrl before update_float_image",
|
||||
"sheet.update_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_pivot_table --properties": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
|
||||
@@ -15,21 +15,18 @@ func newFloatImageCmds() []*cobra.Command {
|
||||
|
||||
浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。
|
||||
|
||||
直接传 --file 可在一个命令中上传本地图片并创建浮动图片。
|
||||
已经通过 media-upload 获得 resourceUrl 时,也可以改传 --src;--file 与 --src 二选一。
|
||||
使用流程:
|
||||
1. 先通过 media-upload 上传本地图片获取 resourceUrl
|
||||
2. 再通过 create-float-image 将图片以浮动方式放置到工作表上
|
||||
|
||||
--range 指定浮动图片锚定的单元格位置,使用 A1 表示法(如 A1、B3)。
|
||||
--width / --height 为必填,单位像素,必须为正整数。
|
||||
--offset-x / --offset-y 可选,表示相对锚点单元格左上角的偏移量(像素),默认 0。`,
|
||||
Example: ` # 直接从本地文件创建
|
||||
dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \
|
||||
--file ./chart.png --range A1 --width 400 --height 300
|
||||
|
||||
# 高级用法:先上传图片获取 resourceUrl
|
||||
Example: ` # 先上传图片获取 resourceUrl
|
||||
dws sheet media-upload --node NODE_ID --file ./chart.png
|
||||
# 输出: resourceUrl: /core/api/resources/img/xxxx...
|
||||
|
||||
# 再通过 --src 创建浮动图片
|
||||
# 再创建浮动图片(--src 传入 media-upload 返回的 resourceUrl)
|
||||
dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \
|
||||
--src "/core/api/resources/img/xxxx..." --range A1 --width 400 --height 300
|
||||
|
||||
@@ -37,11 +34,7 @@ func newFloatImageCmds() []*cobra.Command {
|
||||
dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \
|
||||
--src "/core/api/resources/img/xxxx..." --range B2 --width 200 --height 150 --offset-x 10 --offset-y 20`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil {
|
||||
return err
|
||||
}
|
||||
filePath, src, err := validateFloatImageCreateInput(cmd)
|
||||
if err != nil {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "src", "range"); err != nil {
|
||||
return err
|
||||
}
|
||||
width, err := cmd.Flags().GetInt("width")
|
||||
@@ -61,6 +54,7 @@ func newFloatImageCmds() []*cobra.Command {
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"src": mustGetFlag(cmd, "src"),
|
||||
"range": mustGetFlag(cmd, "range"),
|
||||
"width": width,
|
||||
"height": height,
|
||||
@@ -79,11 +73,7 @@ func newFloatImageCmds() []*cobra.Command {
|
||||
}
|
||||
toolArgs["offsetY"] = oy
|
||||
}
|
||||
if filePath != "" {
|
||||
return runFloatImageFileMode(cmd, "create_float_image", filePath, toolArgs)
|
||||
}
|
||||
toolArgs["src"] = src
|
||||
return callMCPToolContext(cmd.Context(), "create_float_image", toolArgs)
|
||||
return callMCPTool("create_float_image", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(createFloatImageCmd, LeafSpec{
|
||||
@@ -99,30 +89,26 @@ func newFloatImageCmds() []*cobra.Command {
|
||||
CLIPath: "sheet create-float-image",
|
||||
PrimaryCLIPath: "sheet create-float-image",
|
||||
},
|
||||
Description: "从本地文件或已上传的 resourceUrl 创建浮动图片。",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||||
Description: "创建浮动图片(src 必须来自 media-upload 的 resourceUrl)。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "create_float_image"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "从本地文件或已上传的 resourceUrl 创建浮动图片。",
|
||||
AgentSummary: "创建浮动图片(src 必须来自 media-upload 的 resourceUrl)。",
|
||||
UseWhen: []string{"需要在单元格上方悬浮图片、不占用单元格内容时"},
|
||||
AvoidWhen: []string{"单元格内嵌图片用 write-image;更新/删除浮动图用 update/delete-float-image"},
|
||||
Examples: []string{"dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --src \"/core/api/resources/img/...\" --range A1 --width 400 --height 300"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "file", Required: boolPtr(false), RequiredWhen: "exactly one of --file or --src must be provided"},
|
||||
{Name: "src", Property: "src", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
createFloatImageCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
createFloatImageCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
createFloatImageCmd.Flags().String("file", "", "本地图片文件路径,与 --src 二选一")
|
||||
createFloatImageCmd.Flags().String("src", "", "通过 media-upload 获取的 resourceUrl,与 --file 二选一")
|
||||
createFloatImageCmd.Flags().String("src", "", "图片资源路径,通过 media-upload 获取的 resourceUrl (必填)")
|
||||
createFloatImageCmd.Flags().String("range", "", "锚点单元格,A1 表示法,如 A1、B3 (必填)")
|
||||
createFloatImageCmd.Flags().Int("width", 0, "图片宽度,像素 (必填)")
|
||||
createFloatImageCmd.Flags().Int("height", 0, "图片高度,像素 (必填)")
|
||||
@@ -231,8 +217,8 @@ floatImageId 可通过 list-float-images 获取。`,
|
||||
Short: "更新浮动图片属性",
|
||||
Long: `更新钉钉表格指定工作表中浮动图片的属性。
|
||||
|
||||
可直接传 --file 上传本地图片并替换浮动图片,也可传已上传图片的 --src。
|
||||
至少需要传入一个更新字段(--file / --src / --range / --width / --height / --offset-x / --offset-y),且 --file 与 --src 不能同时使用。
|
||||
可更新的属性包括:图片资源路径(src)、锚点位置、尺寸、偏移量。
|
||||
至少需要传入一个更新字段(--src / --range / --width / --height / --offset-x / --offset-y)。
|
||||
floatImageId 可通过 list-float-images 获取。`,
|
||||
Example: ` # 移动浮动图片到新位置
|
||||
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID --range C5
|
||||
@@ -240,15 +226,10 @@ floatImageId 可通过 list-float-images 获取。`,
|
||||
# 调整尺寸
|
||||
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID --width 600 --height 400
|
||||
|
||||
# 直接用本地文件替换图片
|
||||
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID \
|
||||
--file ./replacement.png
|
||||
|
||||
# 高级用法:用 media-upload 返回的 resourceUrl 替换图片
|
||||
# 替换图片(需先 media-upload 新图片获取 resourceUrl)
|
||||
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID \
|
||||
--src "/core/api/resources/img/xxxx..."`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fileChanged := cmd.Flags().Changed("file")
|
||||
srcChanged := cmd.Flags().Changed("src")
|
||||
rangeChanged := cmd.Flags().Changed("range")
|
||||
widthChanged := cmd.Flags().Changed("width")
|
||||
@@ -256,17 +237,8 @@ floatImageId 可通过 list-float-images 获取。`,
|
||||
oxChanged := cmd.Flags().Changed("offset-x")
|
||||
oyChanged := cmd.Flags().Changed("offset-y")
|
||||
|
||||
if !fileChanged && !srcChanged && !rangeChanged && !widthChanged && !heightChanged && !oxChanged && !oyChanged {
|
||||
return fmt.Errorf("%s", floatImageUpdateFieldsError)
|
||||
}
|
||||
filePath, src, err := validateFloatImageUpdateInput(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fileChanged {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "float-image-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if !srcChanged && !rangeChanged && !widthChanged && !heightChanged && !oxChanged && !oyChanged {
|
||||
return fmt.Errorf("--src、--range、--width、--height、--offset-x、--offset-y 至少必须提供一个")
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
@@ -275,7 +247,7 @@ floatImageId 可通过 list-float-images 获取。`,
|
||||
"floatImageId": mustGetFlag(cmd, "float-image-id"),
|
||||
}
|
||||
if srcChanged {
|
||||
toolArgs["src"] = src
|
||||
toolArgs["src"], _ = cmd.Flags().GetString("src")
|
||||
}
|
||||
if rangeChanged {
|
||||
toolArgs["range"], _ = cmd.Flags().GetString("range")
|
||||
@@ -308,10 +280,7 @@ floatImageId 可通过 list-float-images 获取。`,
|
||||
}
|
||||
toolArgs["offsetY"] = oy
|
||||
}
|
||||
if fileChanged {
|
||||
return runFloatImageFileMode(cmd, "update_float_image", filePath, toolArgs)
|
||||
}
|
||||
return callMCPToolContext(cmd.Context(), "update_float_image", toolArgs)
|
||||
return callMCPTool("update_float_image", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(updateFloatImageCmd, LeafSpec{
|
||||
@@ -328,7 +297,6 @@ floatImageId 可通过 list-float-images 获取。`,
|
||||
PrimaryCLIPath: "sheet update-float-image",
|
||||
},
|
||||
Description: "更新浮动图片锚点、尺寸、偏移或资源路径。",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
@@ -342,15 +310,12 @@ floatImageId 可通过 list-float-images 获取。`,
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "file", Required: boolPtr(false)},
|
||||
{Name: "src", Property: "src", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
updateFloatImageCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
updateFloatImageCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
updateFloatImageCmd.Flags().String("float-image-id", "", "浮动图片 ID (必填)")
|
||||
updateFloatImageCmd.Flags().String("file", "", "用于替换浮动图片的本地图片路径,与 --src 不能同时使用")
|
||||
updateFloatImageCmd.Flags().String("src", "", "新的图片资源路径,通过 media-upload 获取的 resourceUrl")
|
||||
updateFloatImageCmd.Flags().String("range", "", "新的锚点单元格,A1 表示法")
|
||||
updateFloatImageCmd.Flags().Int("width", 0, "新的图片宽度,像素")
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
floatImageCreateInputError = "--file 与 --src 必须且只能提供一个"
|
||||
floatImageUpdateInputError = "--file 与 --src 不能同时提供"
|
||||
floatImageUpdateFieldsError = "--file、--src、--range、--width、--height、--offset-x、--offset-y 至少必须提供一个"
|
||||
floatImagePlannedResource = "<resourceUrl-after-upload>"
|
||||
floatImageUploadTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
type floatImageLocalFile struct {
|
||||
file *os.File
|
||||
path string
|
||||
name string
|
||||
mimeType string
|
||||
size int64
|
||||
}
|
||||
|
||||
type floatImageUploadInfo struct {
|
||||
uploadURL string
|
||||
resourceID string
|
||||
resourceURL string
|
||||
}
|
||||
|
||||
func validateFloatImageCreateInput(cmd *cobra.Command) (string, string, error) {
|
||||
filePath, _ := cmd.Flags().GetString("file")
|
||||
src, _ := cmd.Flags().GetString("src")
|
||||
if cmd.Flags().Changed("file") && strings.TrimSpace(filePath) == "" {
|
||||
return "", "", fmt.Errorf("--file 不能为空")
|
||||
}
|
||||
hasFile := strings.TrimSpace(filePath) != ""
|
||||
hasSrc := strings.TrimSpace(src) != ""
|
||||
if hasFile == hasSrc {
|
||||
return "", "", fmt.Errorf("%s", floatImageCreateInputError)
|
||||
}
|
||||
return filePath, src, nil
|
||||
}
|
||||
|
||||
func validateFloatImageUpdateInput(cmd *cobra.Command) (string, string, error) {
|
||||
filePath, _ := cmd.Flags().GetString("file")
|
||||
src, _ := cmd.Flags().GetString("src")
|
||||
if cmd.Flags().Changed("file") && cmd.Flags().Changed("src") {
|
||||
return "", "", fmt.Errorf("%s", floatImageUpdateInputError)
|
||||
}
|
||||
if cmd.Flags().Changed("file") && strings.TrimSpace(filePath) == "" {
|
||||
return "", "", fmt.Errorf("--file 不能为空")
|
||||
}
|
||||
return filePath, src, nil
|
||||
}
|
||||
|
||||
func runFloatImageFileMode(cmd *cobra.Command, toolName, filePath string, toolArgs map[string]any) error {
|
||||
local, err := openFloatImageLocalFile(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer local.file.Close()
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
return printFloatImageFileDryRun(toolName, toolArgs, local)
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
jsonMode := strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json")
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取浮动图片上传凭证 (%s, %d bytes)...", local.name, local.size))
|
||||
}
|
||||
uploadInfo, err := resolveFloatImageUploadInfo(ctx, toolArgs["nodeId"], local)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !jsonMode {
|
||||
deps.Out.PrintKeyValue("resourceId", uploadInfo.resourceID)
|
||||
deps.Out.PrintKeyValue("resourceUrl", uploadInfo.resourceURL)
|
||||
deps.Out.PrintInfo("[2/3] 上传本地图片...")
|
||||
}
|
||||
client := newFloatImageUploadClient()
|
||||
if err := putFloatImageFileWithClient(ctx, client, uploadInfo.uploadURL, local); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo("[3/3] 提交浮动图片变更...")
|
||||
}
|
||||
toolArgs["src"] = uploadInfo.resourceURL
|
||||
if err := callMCPToolContext(ctx, toolName, toolArgs); err != nil {
|
||||
return fmt.Errorf("图片已上传(resourceId=%s,resourceUrl=%s),但浮动图片操作失败;已有重试可能造成重复创建或更新,请先用 list/get/UI 核验后再重试: %w", uploadInfo.resourceID, uploadInfo.resourceURL, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func openFloatImageLocalFile(path string) (*floatImageLocalFile, error) {
|
||||
return openFloatImageLocalFileWith(path, os.Stat, os.Open)
|
||||
}
|
||||
|
||||
func openFloatImageLocalFileWith(path string, stat func(string) (os.FileInfo, error), open func(string) (*os.File, error)) (*floatImageLocalFile, error) {
|
||||
info, err := stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法读取 --file %q: %w", path, err)
|
||||
}
|
||||
if err := validateFloatImageFileInfo(info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法打开 --file %q: %w", path, err)
|
||||
}
|
||||
openedInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("无法读取已打开的 --file %q: %w", path, err)
|
||||
}
|
||||
if err := validateFloatImageFileInfo(openedInfo); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &floatImageLocalFile{
|
||||
file: file,
|
||||
path: path,
|
||||
name: filepath.Base(path),
|
||||
mimeType: inferMimeType(filepath.Base(path)),
|
||||
size: openedInfo.Size(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateFloatImageFileInfo(info os.FileInfo) error {
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("--file 必须是普通文件")
|
||||
}
|
||||
if info.Size() <= 0 {
|
||||
return fmt.Errorf("--file 不能为空文件")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printFloatImageFileDryRun(toolName string, toolArgs map[string]any, local *floatImageLocalFile) error {
|
||||
arguments := make(map[string]any, len(toolArgs)+1)
|
||||
for key, value := range toolArgs {
|
||||
arguments[key] = value
|
||||
}
|
||||
arguments["src"] = floatImagePlannedResource
|
||||
stages := []string{"validate_local_file", "get_upload_credentials", "upload_file", toolName}
|
||||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"tool": toolName,
|
||||
"arguments": arguments,
|
||||
"local_file": map[string]any{
|
||||
"path": local.path, "name": local.name, "mime_type": local.mimeType, "size": local.size,
|
||||
},
|
||||
"stages": stages,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintKeyValue("操作", "上传本地图片并提交浮动图片变更")
|
||||
deps.Out.PrintKeyValue("文件", local.path)
|
||||
deps.Out.PrintKeyValue("名称", local.name)
|
||||
deps.Out.PrintKeyValue("类型", local.mimeType)
|
||||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", local.size))
|
||||
deps.Out.PrintKeyValue("Tool", toolName)
|
||||
deps.Out.PrintKeyValue("阶段", strings.Join(stages, " -> "))
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveFloatImageUploadInfo(ctx context.Context, nodeID any, local *floatImageLocalFile) (floatImageUploadInfo, error) {
|
||||
result, err := deps.Caller.CallTool(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"fileName": local.name,
|
||||
"fileSize": float64(local.size),
|
||||
"mimeType": local.mimeType,
|
||||
})
|
||||
if err != nil {
|
||||
return floatImageUploadInfo{}, WrapError(err)
|
||||
}
|
||||
return parseFloatImageUploadInfo(result)
|
||||
}
|
||||
|
||||
func parseFloatImageUploadInfo(result *edition.ToolResult) (floatImageUploadInfo, error) {
|
||||
if result == nil {
|
||||
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
|
||||
}
|
||||
text := ""
|
||||
for _, content := range result.Content {
|
||||
if content.Type == "text" && strings.TrimSpace(content.Text) != "" {
|
||||
text = content.Text
|
||||
break
|
||||
}
|
||||
}
|
||||
if text == "" {
|
||||
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
|
||||
}
|
||||
type credential struct {
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
ResourceURL string `json:"resourceUrl"`
|
||||
}
|
||||
var envelope struct {
|
||||
credential
|
||||
Result *credential `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(text), &envelope); err != nil {
|
||||
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
|
||||
}
|
||||
parsed := envelope.credential
|
||||
if envelope.Result != nil {
|
||||
parsed = *envelope.Result
|
||||
}
|
||||
parsed.UploadURL = strings.TrimSpace(parsed.UploadURL)
|
||||
parsed.ResourceID = strings.TrimSpace(parsed.ResourceID)
|
||||
parsed.ResourceURL = strings.TrimSpace(parsed.ResourceURL)
|
||||
if parsed.UploadURL == "" || !validFloatImageResourceID(parsed.ResourceID) || !validFloatImageResourceURL(parsed.ResourceURL) {
|
||||
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
|
||||
}
|
||||
if err := validateFloatImageUploadURL(parsed.UploadURL); err != nil {
|
||||
return floatImageUploadInfo{}, err
|
||||
}
|
||||
return floatImageUploadInfo{uploadURL: parsed.UploadURL, resourceID: parsed.ResourceID, resourceURL: parsed.ResourceURL}, nil
|
||||
}
|
||||
|
||||
func invalidFloatImageCredentialError() error {
|
||||
return &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: "获取浮动图片上传凭证失败:响应格式无效",
|
||||
Suggestion: "请重试;若持续失败,请检查 doc/get_doc_attachment_upload_info 服务",
|
||||
Operation: "doc/get_doc_attachment_upload_info",
|
||||
}
|
||||
}
|
||||
|
||||
func validFloatImageResourceID(value string) bool {
|
||||
return value != "" && len(value) <= 4096 && strings.IndexFunc(value, unicode.IsControl) < 0
|
||||
}
|
||||
|
||||
func validFloatImageResourceURL(value string) bool {
|
||||
if value == "" || len(value) > 8192 || strings.IndexFunc(value, unicode.IsControl) >= 0 || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
return err == nil && parsed.Host == "" && parsed.RawQuery == "" && parsed.Fragment == ""
|
||||
}
|
||||
|
||||
// Keep this policy aligned with internal/transport.Client.isEndpointTrusted.
|
||||
// It stays Sheet-private in V1 so local-file float-image support does not
|
||||
// refactor the shared transport boundary selected by unrelated products.
|
||||
func validateFloatImageUploadURL(rawURL string) error {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || !parsed.IsAbs() || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
|
||||
return invalidFloatImageUploadURLError()
|
||||
}
|
||||
if strings.EqualFold(parsed.Scheme, "https") {
|
||||
return nil
|
||||
}
|
||||
if !strings.EqualFold(parsed.Scheme, "http") || os.Getenv("DWS_ALLOW_HTTP_ENDPOINTS") != "1" || !isFloatImageLoopbackHost(parsed.Hostname()) {
|
||||
return invalidFloatImageUploadURLError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isFloatImageLoopbackHost(host string) bool {
|
||||
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
func invalidFloatImageUploadURLError() error {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: "浮动图片上传地址无效或不受信任",
|
||||
Suggestion: "上传地址必须是 HTTPS;本地开发仅允许显式开启的 loopback HTTP",
|
||||
Operation: "sheet/float-image-upload",
|
||||
}
|
||||
}
|
||||
|
||||
func newFloatImageUploadClient() *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: floatImageUploadTimeout,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// This intentionally does not reuse doc.defaultHTTPPutFile or localio.PutFile:
|
||||
// both have incompatible retry/status/host behavior, and the Doc helper can
|
||||
// copy a signed URL or response body into errors. Keep this fixed-message PUT
|
||||
// until a shared secure-upload contract can replace all upload implementations.
|
||||
func putFloatImageFileWithClient(ctx context.Context, client *http.Client, rawURL string, local *floatImageLocalFile) error {
|
||||
if client == nil {
|
||||
return fmt.Errorf("浮动图片文件上传失败:HTTP 客户端不可用")
|
||||
}
|
||||
if _, err := local.file.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("浮动图片文件上传失败:无法读取本地文件")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, local.file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("浮动图片文件上传失败:无法创建请求")
|
||||
}
|
||||
request.ContentLength = local.size
|
||||
request.Header.Set("Content-Type", local.mimeType)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return fmt.Errorf("浮动图片文件上传已取消: %w", ctxErr)
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || os.IsTimeout(err) {
|
||||
return fmt.Errorf("浮动图片文件上传失败:已超过传输时限")
|
||||
}
|
||||
return fmt.Errorf("浮动图片文件上传失败:网络请求失败")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10))
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("浮动图片文件上传失败:HTTP %d", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
//go:build darwin || linux || freebsd || openbsd || netbsd || dragonfly
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageFIFORejectedBeforeOpen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "image.fifo")
|
||||
if err := syscall.Mkfifo(path, 0o600); err != nil {
|
||||
t.Fatalf("mkfifo: %v", err)
|
||||
}
|
||||
if _, err := openFloatImageLocalFile(path); err == nil || !strings.Contains(err.Error(), "普通文件") {
|
||||
t.Fatalf("FIFO error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,695 +0,0 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type floatImageToolCall struct {
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type floatImageTestCaller struct {
|
||||
dryRun bool
|
||||
format string
|
||||
calls []floatImageToolCall
|
||||
call func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error)
|
||||
}
|
||||
|
||||
func (c *floatImageTestCaller) CallTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copyArgs := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copyArgs[key] = value
|
||||
}
|
||||
index := len(c.calls)
|
||||
c.calls = append(c.calls, floatImageToolCall{product: product, tool: tool, args: copyArgs})
|
||||
if c.call != nil {
|
||||
return c.call(ctx, product, tool, args, index)
|
||||
}
|
||||
return floatImageTextResult(`{"floatImage":{"id":"fi-1"}}`), nil
|
||||
}
|
||||
|
||||
func (c *floatImageTestCaller) Format() string {
|
||||
if c.format == "" {
|
||||
return "json"
|
||||
}
|
||||
return c.format
|
||||
}
|
||||
|
||||
func (c *floatImageTestCaller) DryRun() bool { return c.dryRun }
|
||||
func (*floatImageTestCaller) Fields() string { return "" }
|
||||
func (*floatImageTestCaller) JQ() string { return "" }
|
||||
|
||||
type floatImageRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn floatImageRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return fn(request)
|
||||
}
|
||||
|
||||
func floatImageTextResult(text string) *edition.ToolResult {
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
|
||||
}
|
||||
|
||||
func floatImageCredentialResult(uploadURL string, nested bool) *edition.ToolResult {
|
||||
credential := map[string]any{
|
||||
"uploadUrl": uploadURL, "resourceId": "rid-1", "resourceUrl": "/core/api/resources/img/rid-1",
|
||||
}
|
||||
payload := any(credential)
|
||||
if nested {
|
||||
payload = map[string]any{"result": credential}
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
return floatImageTextResult(string(raw))
|
||||
}
|
||||
|
||||
func floatImageCommandForTest(t *testing.T, name string) *cobra.Command {
|
||||
t.Helper()
|
||||
for _, command := range newFloatImageCmds() {
|
||||
if command.Name() == name {
|
||||
return command
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing float-image command %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func setFloatImageFlags(t *testing.T, command *cobra.Command, values map[string]string) {
|
||||
t.Helper()
|
||||
for name, value := range values {
|
||||
if err := command.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installFloatImageDeps(t *testing.T, caller *floatImageTestCaller, output io.Writer) {
|
||||
t.Helper()
|
||||
if output == nil {
|
||||
output = io.Discard
|
||||
}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: NewFormatterWithWriters(output, io.Discard)})
|
||||
previousArgs := os.Args
|
||||
os.Args = []string{"dws", "sheet"}
|
||||
t.Cleanup(func() { os.Args = previousArgs })
|
||||
}
|
||||
|
||||
func writeFloatImageFixture(t *testing.T, name, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func openFloatImageFixture(t *testing.T, path string) *floatImageLocalFile {
|
||||
t.Helper()
|
||||
local, err := openFloatImageLocalFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open fixture: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.file.Close() })
|
||||
return local
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageInputValidation(t *testing.T) {
|
||||
create := floatImageCommandForTest(t, "create-float-image")
|
||||
if _, _, err := validateFloatImageCreateInput(create); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) {
|
||||
t.Fatalf("missing create input error = %v", err)
|
||||
}
|
||||
setFloatImageFlags(t, create, map[string]string{"file": "a.png", "src": "/img/a"})
|
||||
if _, _, err := validateFloatImageCreateInput(create); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) {
|
||||
t.Fatalf("conflicting create input error = %v", err)
|
||||
}
|
||||
createEmptyFile := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, createEmptyFile, map[string]string{"file": " ", "src": "/img/a"})
|
||||
if _, _, err := validateFloatImageCreateInput(createEmptyFile); err == nil || !strings.Contains(err.Error(), "--file 不能为空") {
|
||||
t.Fatalf("empty create file error = %v", err)
|
||||
}
|
||||
|
||||
fileOnly := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, fileOnly, map[string]string{"file": " a.png "})
|
||||
if file, src, err := validateFloatImageCreateInput(fileOnly); err != nil || file != " a.png " || src != "" {
|
||||
t.Fatalf("file-only input = %q/%q/%v", file, src, err)
|
||||
}
|
||||
srcOnly := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, srcOnly, map[string]string{"src": " /img/a "})
|
||||
if file, src, err := validateFloatImageCreateInput(srcOnly); err != nil || file != "" || src != " /img/a " {
|
||||
t.Fatalf("src-only input = %q/%q/%v", file, src, err)
|
||||
}
|
||||
|
||||
updateBoth := floatImageCommandForTest(t, "update-float-image")
|
||||
setFloatImageFlags(t, updateBoth, map[string]string{"file": "a.png", "src": "/img/a"})
|
||||
if _, _, err := validateFloatImageUpdateInput(updateBoth); err == nil || !strings.Contains(err.Error(), floatImageUpdateInputError) {
|
||||
t.Fatalf("conflicting update input error = %v", err)
|
||||
}
|
||||
updateEmptyFile := floatImageCommandForTest(t, "update-float-image")
|
||||
setFloatImageFlags(t, updateEmptyFile, map[string]string{"file": " "})
|
||||
if _, _, err := validateFloatImageUpdateInput(updateEmptyFile); err == nil || !strings.Contains(err.Error(), "--file 不能为空") {
|
||||
t.Fatalf("empty update file error = %v", err)
|
||||
}
|
||||
updateSrc := floatImageCommandForTest(t, "update-float-image")
|
||||
setFloatImageFlags(t, updateSrc, map[string]string{"src": ""})
|
||||
if file, src, err := validateFloatImageUpdateInput(updateSrc); err != nil || file != "" || src != "" {
|
||||
t.Fatalf("src update compatibility = %q/%q/%v", file, src, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageLocalFileValidation(t *testing.T) {
|
||||
validPath := writeFloatImageFixture(t, "chart.png", "png-data")
|
||||
local, err := openFloatImageLocalFile(validPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if local.name != "chart.png" || local.mimeType != "image/png" || local.size != int64(len("png-data")) {
|
||||
t.Fatalf("local metadata = %#v", local)
|
||||
}
|
||||
_ = local.file.Close()
|
||||
|
||||
if _, err := openFloatImageLocalFile(filepath.Join(t.TempDir(), "missing.png")); err == nil || !strings.Contains(err.Error(), "无法读取") {
|
||||
t.Fatalf("missing file error = %v", err)
|
||||
}
|
||||
if _, err := openFloatImageLocalFile(t.TempDir()); err == nil || !strings.Contains(err.Error(), "普通文件") {
|
||||
t.Fatalf("directory error = %v", err)
|
||||
}
|
||||
emptyPath := writeFloatImageFixture(t, "empty.png", "")
|
||||
if _, err := openFloatImageLocalFile(emptyPath); err == nil || !strings.Contains(err.Error(), "不能为空文件") {
|
||||
t.Fatalf("empty error = %v", err)
|
||||
}
|
||||
|
||||
validInfo, err := os.Stat(validPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statValid := func(string) (os.FileInfo, error) { return validInfo, nil }
|
||||
openFailure := errors.New("open sentinel")
|
||||
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return nil, openFailure }); !errors.Is(err, openFailure) {
|
||||
t.Fatalf("open error = %v", err)
|
||||
}
|
||||
closed, err := os.Open(validPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = closed.Close()
|
||||
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return closed, nil }); err == nil || !strings.Contains(err.Error(), "已打开") {
|
||||
t.Fatalf("opened stat error = %v", err)
|
||||
}
|
||||
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return os.Open(t.TempDir()) }); err == nil || !strings.Contains(err.Error(), "普通文件") {
|
||||
t.Fatalf("descriptor type error = %v", err)
|
||||
}
|
||||
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return os.Open(emptyPath) }); err == nil || !strings.Contains(err.Error(), "不能为空文件") {
|
||||
t.Fatalf("descriptor size error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageCredentialParsingAndURLPolicy(t *testing.T) {
|
||||
invalidResults := []*edition.ToolResult{
|
||||
nil,
|
||||
{},
|
||||
{Content: []edition.ContentBlock{{Type: "image", Text: "credential-secret"}}},
|
||||
floatImageTextResult("{"),
|
||||
floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"rid"}`),
|
||||
floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"bad\nvalue","resourceUrl":"/core/api/resources/img/rid"}`),
|
||||
floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"rid","resourceUrl":"https://resource.invalid/rid"}`),
|
||||
floatImageTextResult(`{"uploadUrl":"http://example.com/signed-secret","resourceId":"rid","resourceUrl":"/core/api/resources/img/rid"}`),
|
||||
}
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "")
|
||||
for index, result := range invalidResults {
|
||||
_, err := parseFloatImageUploadInfo(result)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid result %d succeeded", index)
|
||||
}
|
||||
if strings.Contains(err.Error(), "signed-secret") || strings.Contains(err.Error(), "credential-secret") {
|
||||
t.Fatalf("invalid result %d leaked secret: %v", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
top := floatImageTextResult(`{"uploadUrl":"https://upload.invalid/path?signature=secret","resourceId":"rid-top","resourceUrl":"/core/api/resources/img/top"}`)
|
||||
info, err := parseFloatImageUploadInfo(top)
|
||||
if err != nil || info.resourceID != "rid-top" || info.resourceURL != "/core/api/resources/img/top" {
|
||||
t.Fatalf("top-level credential = %#v, %v", info, err)
|
||||
}
|
||||
nested := &edition.ToolResult{Content: []edition.ContentBlock{
|
||||
{Type: "text", Text: " "},
|
||||
{Type: "image", Text: "ignored"},
|
||||
{Type: "text", Text: `{"result":{"uploadUrl":"https://upload.invalid/nested","resourceId":"rid-nested","resourceUrl":"/core/api/resources/img/nested"}}`},
|
||||
}}
|
||||
info, err = parseFloatImageUploadInfo(nested)
|
||||
if err != nil || info.resourceID != "rid-nested" {
|
||||
t.Fatalf("nested credential = %#v, %v", info, err)
|
||||
}
|
||||
|
||||
for _, value := range []string{"", "//host/path", "/path?secret=x", "/path#fragment", "/bad\npath"} {
|
||||
if validFloatImageResourceURL(value) {
|
||||
t.Errorf("resource URL %q unexpectedly valid", value)
|
||||
}
|
||||
}
|
||||
if !validFloatImageResourceURL("/core/api/resources/img/ok") || validFloatImageResourceID("") || validFloatImageResourceID("bad\x1bvalue") {
|
||||
t.Fatal("resource identifier validation mismatch")
|
||||
}
|
||||
|
||||
for _, rawURL := range []string{"not a URL", "/relative", "https://user:pass@example.com/path", "https://example.com/path#fragment", "ftp://example.com/path", "http://example.com/path"} {
|
||||
if err := validateFloatImageUploadURL(rawURL); err == nil {
|
||||
t.Errorf("upload URL %q unexpectedly valid", rawURL)
|
||||
}
|
||||
}
|
||||
if err := validateFloatImageUploadURL("https://example.com/path?signature=secret"); err != nil {
|
||||
t.Fatalf("HTTPS URL rejected: %v", err)
|
||||
}
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
for _, rawURL := range []string{"http://localhost/path", "http://127.0.0.1/path", "http://[::1]/path"} {
|
||||
if err := validateFloatImageUploadURL(rawURL); err != nil {
|
||||
t.Errorf("loopback URL %q rejected: %v", rawURL, err)
|
||||
}
|
||||
}
|
||||
if err := validateFloatImageUploadURL("http://192.0.2.1/path"); err == nil || isFloatImageLoopbackHost("not-an-ip") || isFloatImageLoopbackHost("127.0.0.2") {
|
||||
t.Fatalf("non-loopback policy mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageHTTPPut(t *testing.T) {
|
||||
path := writeFloatImageFixture(t, "chart.png", "streamed-image")
|
||||
|
||||
t.Run("nil client", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
if err := putFloatImageFileWithClient(context.Background(), nil, "https://upload.invalid", local); err == nil {
|
||||
t.Fatal("nil client succeeded")
|
||||
}
|
||||
})
|
||||
t.Run("closed file", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
_ = local.file.Close()
|
||||
if err := putFloatImageFileWithClient(context.Background(), http.DefaultClient, "https://upload.invalid", local); err == nil || !strings.Contains(err.Error(), "无法读取本地文件") {
|
||||
t.Fatalf("closed file error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("invalid request", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
if err := putFloatImageFileWithClient(context.Background(), http.DefaultClient, ":", local); err == nil || !strings.Contains(err.Error(), "无法创建请求") {
|
||||
t.Fatalf("request error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("transport secret redacted", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
client := &http.Client{Transport: floatImageRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("transport signed-secret https://upload.invalid")
|
||||
})}
|
||||
err := putFloatImageFileWithClient(context.Background(), client, "https://upload.invalid/signed-secret", local)
|
||||
if err == nil || strings.Contains(err.Error(), "signed-secret") || !strings.Contains(err.Error(), "网络请求失败") {
|
||||
t.Fatalf("transport error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("cancel", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
client := &http.Client{Transport: floatImageRoundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
return nil, request.Context().Err()
|
||||
})}
|
||||
err := putFloatImageFileWithClient(ctx, client, "https://upload.invalid/path", local)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancel error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("client deadline", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
client := &http.Client{Transport: floatImageRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
})}
|
||||
err := putFloatImageFileWithClient(context.Background(), client, "https://upload.invalid/path", local)
|
||||
if err == nil || !strings.Contains(err.Error(), "超过传输时限") {
|
||||
t.Fatalf("deadline error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("non-200 body redacted", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.WriteHeader(http.StatusForbidden)
|
||||
_, _ = io.WriteString(response, "response-body-secret")
|
||||
}))
|
||||
defer server.Close()
|
||||
err := putFloatImageFileWithClient(context.Background(), server.Client(), server.URL+"/signed-secret", local)
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 403") || strings.Contains(err.Error(), "response-body-secret") || strings.Contains(err.Error(), "signed-secret") {
|
||||
t.Fatalf("status error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("stream success", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
var method, contentType, body string
|
||||
var contentLength int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
method = request.Method
|
||||
contentType = request.Header.Get("Content-Type")
|
||||
contentLength = request.ContentLength
|
||||
raw, _ := io.ReadAll(request.Body)
|
||||
body = string(raw)
|
||||
response.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
if err := putFloatImageFileWithClient(context.Background(), server.Client(), server.URL, local); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if method != http.MethodPut || contentType != "image/png" || contentLength != int64(len("streamed-image")) || body != "streamed-image" {
|
||||
t.Fatalf("request = %s %s %d %q", method, contentType, contentLength, body)
|
||||
}
|
||||
})
|
||||
t.Run("redirect not followed", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
destinationCalls := 0
|
||||
destination := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { destinationCalls++ }))
|
||||
defer destination.Close()
|
||||
source := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.Header().Set("Location", destination.URL+"/location-secret")
|
||||
response.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer source.Close()
|
||||
client := newFloatImageUploadClient()
|
||||
if client.Timeout != floatImageUploadTimeout {
|
||||
t.Fatalf("timeout = %v", client.Timeout)
|
||||
}
|
||||
err := putFloatImageFileWithClient(context.Background(), client, source.URL, local)
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 302") || strings.Contains(err.Error(), "location-secret") || destinationCalls != 0 {
|
||||
t.Fatalf("redirect error/calls = %v/%d", err, destinationCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageDryRun(t *testing.T) {
|
||||
path := writeFloatImageFixture(t, "chart.png", "image")
|
||||
for _, format := range []string{"json", "table"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
caller := &floatImageTestCaller{dryRun: true, format: format}
|
||||
var output bytes.Buffer
|
||||
installFloatImageDeps(t, caller, &output)
|
||||
command := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{
|
||||
"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80",
|
||||
})
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run remote calls = %d", len(caller.calls))
|
||||
}
|
||||
if format == "json" {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run: %v; output=%s", err, output.String())
|
||||
}
|
||||
arguments, _ := payload["arguments"].(map[string]any)
|
||||
if payload["tool"] != "create_float_image" || arguments["src"] != floatImagePlannedResource || payload["dry_run"] != true || payload["executed"] != false {
|
||||
t.Fatalf("dry-run payload = %#v", payload)
|
||||
}
|
||||
if _, exists := payload["preview_kind"]; exists {
|
||||
t.Fatalf("request preview unexpectedly declares preview_kind: %#v", payload)
|
||||
}
|
||||
} else if !strings.Contains(output.String(), "validate_local_file -> get_upload_credentials -> upload_file -> create_float_image") {
|
||||
t.Fatalf("pretty dry-run = %s", output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, format := range []string{"json", "table"} {
|
||||
t.Run("update-"+format, func(t *testing.T) {
|
||||
caller := &floatImageTestCaller{dryRun: true, format: format}
|
||||
var output bytes.Buffer
|
||||
installFloatImageDeps(t, caller, &output)
|
||||
command := floatImageCommandForTest(t, "update-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{
|
||||
"node": "node-1", "sheet-id": "sheet-1", "float-image-id": "fi-1", "file": path, "width": "120",
|
||||
})
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("update dry-run remote calls = %d", len(caller.calls))
|
||||
}
|
||||
if format == "json" {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode update dry-run: %v; output=%s", err, output.String())
|
||||
}
|
||||
arguments, _ := payload["arguments"].(map[string]any)
|
||||
if payload["tool"] != "update_float_image" || arguments["src"] != floatImagePlannedResource || arguments["floatImageId"] != "fi-1" || payload["dry_run"] != true || payload["executed"] != false {
|
||||
t.Fatalf("update dry-run payload = %#v", payload)
|
||||
}
|
||||
} else if !strings.Contains(output.String(), "validate_local_file -> get_upload_credentials -> upload_file -> update_float_image") {
|
||||
t.Fatalf("update pretty dry-run = %s", output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageCommandWorkflows(t *testing.T) {
|
||||
path := writeFloatImageFixture(t, "chart.png", "command-image")
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
|
||||
t.Run("create src", func(t *testing.T) {
|
||||
caller := &floatImageTestCaller{}
|
||||
installFloatImageDeps(t, caller, io.Discard)
|
||||
command := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{
|
||||
"node": "node-1", "sheet-id": "sheet-1", "src": "/core/api/resources/img/existing", "range": "A1", "width": "100", "height": "80",
|
||||
})
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].product != "sheet" || caller.calls[0].tool != "create_float_image" || caller.calls[0].args["src"] != "/core/api/resources/img/existing" {
|
||||
t.Fatalf("src calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create and update file", func(t *testing.T) {
|
||||
var uploadBodies []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
raw, _ := io.ReadAll(request.Body)
|
||||
uploadBodies = append(uploadBodies, string(raw))
|
||||
response.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
for _, commandName := range []string{"create-float-image", "update-float-image"} {
|
||||
t.Run(commandName, func(t *testing.T) {
|
||||
caller := &floatImageTestCaller{call: func(_ context.Context, product, tool string, _ map[string]any, index int) (*edition.ToolResult, error) {
|
||||
if index == 0 {
|
||||
if product != "doc" || tool != "get_doc_attachment_upload_info" {
|
||||
t.Fatalf("credential route = %s/%s", product, tool)
|
||||
}
|
||||
return floatImageCredentialResult(server.URL+"/signed-secret", commandName == "update-float-image"), nil
|
||||
}
|
||||
return floatImageTextResult(`{"floatImage":{"id":"fi-ok"}}`), nil
|
||||
}}
|
||||
var output bytes.Buffer
|
||||
installFloatImageDeps(t, caller, &output)
|
||||
command := floatImageCommandForTest(t, commandName)
|
||||
flags := map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path}
|
||||
if commandName == "create-float-image" {
|
||||
flags["range"], flags["width"], flags["height"] = "A1", "100", "80"
|
||||
} else {
|
||||
flags["float-image-id"], flags["width"] = "fi-1", "120"
|
||||
}
|
||||
setFloatImageFlags(t, command, flags)
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[1].product != "sheet" || caller.calls[1].tool != strings.ReplaceAll(commandName, "-", "_") {
|
||||
t.Fatalf("workflow calls = %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["fileName"] != "chart.png" || caller.calls[0].args["mimeType"] != "image/png" || caller.calls[1].args["src"] != "/core/api/resources/img/rid-1" {
|
||||
t.Fatalf("workflow args = %#v", caller.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil || payload["floatImage"] == nil {
|
||||
t.Fatalf("final output = %s, %v", output.String(), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if len(uploadBodies) != 2 || uploadBodies[0] != "command-image" || uploadBodies[1] != "command-image" {
|
||||
t.Fatalf("upload bodies = %#v", uploadBodies)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pretty progress", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusOK) }))
|
||||
defer server.Close()
|
||||
caller := &floatImageTestCaller{format: "table", call: func(_ context.Context, _, _ string, _ map[string]any, index int) (*edition.ToolResult, error) {
|
||||
if index == 0 {
|
||||
return floatImageCredentialResult(server.URL, false), nil
|
||||
}
|
||||
return floatImageTextResult(`{"floatImage":{"id":"fi-pretty"}}`), nil
|
||||
}}
|
||||
var output bytes.Buffer
|
||||
installFloatImageDeps(t, caller, &output)
|
||||
command := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, marker := range []string{"[1/3]", "resourceId", "[2/3]", "[3/3]"} {
|
||||
if !strings.Contains(output.String(), marker) {
|
||||
t.Fatalf("pretty output missing %q: %s", marker, output.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validation and local error", func(t *testing.T) {
|
||||
caller := &floatImageTestCaller{}
|
||||
installFloatImageDeps(t, caller, io.Discard)
|
||||
create := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, create, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "range": "A1", "width": "100", "height": "80"})
|
||||
if err := create.RunE(create, nil); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) {
|
||||
t.Fatalf("create validation = %v", err)
|
||||
}
|
||||
missing := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, missing, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": filepath.Join(t.TempDir(), "missing.png"), "range": "A1", "width": "100", "height": "80"})
|
||||
if err := missing.RunE(missing, nil); err == nil || !strings.Contains(err.Error(), "无法读取") {
|
||||
t.Fatalf("local error = %v", err)
|
||||
}
|
||||
update := floatImageCommandForTest(t, "update-float-image")
|
||||
if err := update.RunE(update, nil); err == nil || !strings.Contains(err.Error(), floatImageUpdateFieldsError) {
|
||||
t.Fatalf("update fields error = %v", err)
|
||||
}
|
||||
conflict := floatImageCommandForTest(t, "update-float-image")
|
||||
setFloatImageFlags(t, conflict, map[string]string{"file": path, "src": "/img/a"})
|
||||
if err := conflict.RunE(conflict, nil); err == nil || !strings.Contains(err.Error(), floatImageUpdateInputError) {
|
||||
t.Fatalf("update conflict = %v", err)
|
||||
}
|
||||
missingIdentity := floatImageCommandForTest(t, "update-float-image")
|
||||
setFloatImageFlags(t, missingIdentity, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path})
|
||||
if err := missingIdentity.RunE(missingIdentity, nil); err == nil || !strings.Contains(err.Error(), "float-image-id") || len(caller.calls) != 0 {
|
||||
t.Fatalf("update identity error/calls = %v/%d", err, len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("credential and put failures", func(t *testing.T) {
|
||||
credentialCause := errors.New("credential transport")
|
||||
caller := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
|
||||
return nil, credentialCause
|
||||
}}
|
||||
installFloatImageDeps(t, caller, io.Discard)
|
||||
command := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
|
||||
if err := command.RunE(command, nil); !errors.Is(err, credentialCause) {
|
||||
t.Fatalf("credential error = %v", err)
|
||||
}
|
||||
|
||||
secret := "credential-response-secret"
|
||||
malformed := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
|
||||
return floatImageTextResult(secret), nil
|
||||
}}
|
||||
installFloatImageDeps(t, malformed, io.Discard)
|
||||
command = floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
|
||||
if err := command.RunE(command, nil); err == nil || strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("malformed credential error = %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(response, "put-body-secret")
|
||||
}))
|
||||
defer server.Close()
|
||||
putCaller := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
|
||||
return floatImageCredentialResult(server.URL+"/signed-secret", false), nil
|
||||
}}
|
||||
installFloatImageDeps(t, putCaller, io.Discard)
|
||||
command = floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
|
||||
if err := command.RunE(command, nil); err == nil || len(putCaller.calls) != 1 || strings.Contains(err.Error(), "put-body-secret") || strings.Contains(err.Error(), "signed-secret") {
|
||||
t.Fatalf("put error/calls = %v/%d", err, len(putCaller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial final failure", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusOK) }))
|
||||
defer server.Close()
|
||||
finalCause := errors.New("final operation sentinel")
|
||||
caller := &floatImageTestCaller{call: func(_ context.Context, _, _ string, _ map[string]any, index int) (*edition.ToolResult, error) {
|
||||
if index == 0 {
|
||||
return floatImageCredentialResult(server.URL+"/signed-secret", false), nil
|
||||
}
|
||||
return nil, finalCause
|
||||
}}
|
||||
installFloatImageDeps(t, caller, io.Discard)
|
||||
command := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
|
||||
err := command.RunE(command, nil)
|
||||
if !errors.Is(err, finalCause) || !strings.Contains(err.Error(), "resourceId=rid-1") || !strings.Contains(err.Error(), "list/get/UI") || strings.Contains(err.Error(), "signed-secret") {
|
||||
t.Fatalf("partial failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("update move only", func(t *testing.T) {
|
||||
caller := &floatImageTestCaller{}
|
||||
installFloatImageDeps(t, caller, io.Discard)
|
||||
command := floatImageCommandForTest(t, "update-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "float-image-id": "fi-1", "range": "C5"})
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].args["range"] != "C5" {
|
||||
t.Fatalf("move-only calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveFloatImageUploadInfo(t *testing.T) {
|
||||
path := writeFloatImageFixture(t, "chart.webp", "webp")
|
||||
local := openFloatImageFixture(t, path)
|
||||
caller := &floatImageTestCaller{call: func(ctx context.Context, product, tool string, args map[string]any, _ int) (*edition.ToolResult, error) {
|
||||
if ctx == nil || product != "doc" || tool != "get_doc_attachment_upload_info" {
|
||||
t.Fatalf("route/context = %s/%s/%v", product, tool, ctx)
|
||||
}
|
||||
if args["nodeId"] != "node-1" || args["fileName"] != "chart.webp" || args["mimeType"] != "image/webp" || args["fileSize"] != float64(4) {
|
||||
t.Fatalf("credential args = %#v", args)
|
||||
}
|
||||
return floatImageCredentialResult("https://upload.invalid/path", false), nil
|
||||
}}
|
||||
installFloatImageDeps(t, caller, io.Discard)
|
||||
info, err := resolveFloatImageUploadInfo(context.Background(), "node-1", local)
|
||||
if err != nil || info.resourceID != "rid-1" {
|
||||
t.Fatalf("resolve info = %#v/%v", info, err)
|
||||
}
|
||||
|
||||
cause := fmt.Errorf("timeout synthetic")
|
||||
caller.call = func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
|
||||
return nil, cause
|
||||
}
|
||||
if _, err := resolveFloatImageUploadInfo(context.Background(), "node-1", local); !errors.Is(err, cause) {
|
||||
t.Fatalf("resolve error = %v", err)
|
||||
}
|
||||
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
caller.call = func(ctx context.Context, _ string, _ string, _ map[string]any, _ int) (*edition.ToolResult, error) {
|
||||
if !errors.Is(ctx.Err(), context.Canceled) {
|
||||
t.Fatalf("credential context error = %v", ctx.Err())
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if _, err := resolveFloatImageUploadInfo(cancelled, "node-1", local); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled credential error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageUploadDeadlineDocumented(t *testing.T) {
|
||||
client := newFloatImageUploadClient()
|
||||
if client.Timeout != 5*time.Minute || client.CheckRedirect == nil {
|
||||
t.Fatalf("upload client = %#v", client)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
| 清空 / 排序 / 填充 / 复制移动区域 | `range clear` / `range sort` / `range fill` / `range copy-to` / `range move-to` | [sheet-range-operations](sheet/sheet-range-operations.md) | 用读写组合模拟服务端原子操作 |
|
||||
| 合并、冻结、分组、行高列宽 | `sheet info` + 结构命令 | [sheet-workbook](sheet/sheet-workbook.md)、[sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 从 `range read` / CSV 空值推断结构 |
|
||||
| 多个原子写操作组合 | `batch-update` | [sheet-batch-operations](sheet/sheet-batch-operations.md) | 多次独立调用导致半成品 |
|
||||
| 图片写入单元格 / 浮动图片 | `write-image` / `create-float-image --file` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 |
|
||||
| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 |
|
||||
| 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 |
|
||||
| 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 |
|
||||
| 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 |
|
||||
@@ -80,7 +80,7 @@
|
||||
| [sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 行列增删移动、属性设置与分组。当用户说"插入行/列"、"删除行/列"、"隐藏/显示行列"、"设行高/列宽"、"移动行/列"、"追加空行/空列"、"创建/取消行列分组"、"新建分组并设为折叠/展开"时使用。命令:`insert-dimension`/`delete-dimension`/`update-dimension`/`move-dimension`/`add-dimension`/`group-dimension`/`ungroup-dimension` |
|
||||
| [sheet-style-format](sheet/sheet-style-format.md) | 单元格样式与合并。当用户说"设样式"、"改颜色/字体/对齐"、"数字格式(百分比/货币/日期)"、"合并/取消合并"时使用。纯样式/批量样式走 `set-style`;写值同时设置少量 cell 样式可用 `range update` 的 `cellStyles`。命令:`range set-style`/`range batch-set-style`/`merge-cells`/`unmerge-cells` |
|
||||
| [sheet-dropdown](sheet/sheet-dropdown.md) | 下拉列表管理。当用户说"设置下拉"、"下拉选项"、"删除下拉"时使用。命令:`set-dropdown`/`get-dropdown`/`delete-dropdown` |
|
||||
| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片优先用 `create-float-image --file`,已有 resourceUrl 时用 `--src`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` |
|
||||
| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片需先 `media-upload` 再 `create-float-image`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` |
|
||||
| [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` |
|
||||
| [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` |
|
||||
| [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` |
|
||||
@@ -237,7 +237,7 @@ Flags:
|
||||
| "搜索公式文本" | `dws sheet find --node <nodeId或URL> --sheet-id <sheetId> --query "<公式片段>" --match-formula` |
|
||||
| "正则搜索 / 不区分大小写" | `dws sheet find --node <nodeId或URL> --sheet-id <sheetId> --query "<regexp>" --use-regexp --match-case=false` |
|
||||
| "插入图片到单元格" | `dws sheet write-image --node <nodeId或URL> --sheet-id <sheetId> --range A1 --file <图片路径>` |
|
||||
| "创建浮动图片" | `dws sheet create-float-image --node <nodeId或URL> --sheet-id <sheetId> --file <图片路径> --range A1 --width <宽> --height <高>` |
|
||||
| "创建浮动图片" | 先 `dws sheet media-upload --node <nodeId或URL> --file <图片路径>` 获取 `resourceUrl`,再 `dws sheet create-float-image --node <nodeId或URL> --sheet-id <sheetId> --src "<resourceUrl>" --range A1 --width <宽> --height <高>` |
|
||||
|
||||
## URL 与 ID 前置
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
### 浮动图片
|
||||
|
||||
用户说"浮动图片/悬浮图片/在表格上放一张图/加个浮动的图":
|
||||
- 创建浮动图片 → `create-float-image --file <本地图片>`;已有 `resourceUrl` 时可改用 `--src`
|
||||
- 创建浮动图片 → 先 `media-upload` 上传图片获取 `resourceUrl`,再 `create-float-image`
|
||||
- 浮动图片悬浮于单元格之上,不占用单元格内容,与 `write-image`(写入单元格内部的图片)不同
|
||||
|
||||
用户说"查看浮动图片/有哪些浮动图片/浮动图片列表":
|
||||
@@ -71,11 +71,7 @@ Flags:
|
||||
Usage:
|
||||
dws sheet create-float-image [flags]
|
||||
Example:
|
||||
# 直接上传本地图片并创建浮动图片
|
||||
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--file ./chart.png --range A1 --width 400 --height 300
|
||||
|
||||
# 高级用法:先上传图片获取 resourceUrl
|
||||
# 先上传图片获取 resourceUrl
|
||||
dws sheet media-upload --node <NODE_ID> --file ./chart.png
|
||||
# 输出: resourceUrl: /core/api/resources/img/xxxx...
|
||||
|
||||
@@ -89,8 +85,7 @@ Example:
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--file string 本地图片文件路径,与 --src 二选一
|
||||
--src string 图片资源路径,通过 media-upload 获取的 resourceUrl,与 --file 二选一
|
||||
--src string 图片资源路径,通过 media-upload 获取的 resourceUrl (必填)
|
||||
--range string 锚点单元格,A1 表示法,如 A1、B3 (必填)
|
||||
--width int 图片宽度,像素,正整数 (必填)
|
||||
--height int 图片高度,像素,正整数 (必填)
|
||||
@@ -99,8 +94,7 @@ Flags:
|
||||
```
|
||||
|
||||
浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。
|
||||
- `--file` 与 `--src` 必须且只能提供一个;`--file` 会在命令内完成凭证获取、文件上传和浮动图片创建
|
||||
- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL;需要自定义上传名称/MIME 时使用这个高级两步流程
|
||||
- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL
|
||||
- `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
|
||||
- `--width` / `--height` 为必填,单位像素,必须为正整数
|
||||
- `--offset-x` / `--offset-y` 表示相对锚点单元格左上角的偏移量(像素),默认 0,不能为负数
|
||||
@@ -144,18 +138,13 @@ Example:
|
||||
# 调整尺寸
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> --width 600 --height 400
|
||||
|
||||
# 直接用本地图片替换
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> \
|
||||
--file ./replacement.png
|
||||
|
||||
# 高级用法:通过已上传的 resourceUrl 替换
|
||||
# 替换图片(需先 media-upload 新图片获取 resourceUrl)
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> \
|
||||
--src "/core/api/resources/img/xxxx..."
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--float-image-id string 浮动图片 ID (必填)
|
||||
--file string 用于替换浮动图片的本地图片路径,与 --src 不能同时使用
|
||||
--src string 新的图片资源路径,通过 media-upload 获取的 resourceUrl
|
||||
--range string 新的锚点单元格,A1 表示法
|
||||
--width int 新的图片宽度,像素
|
||||
@@ -164,7 +153,7 @@ Flags:
|
||||
--offset-y int 新的垂直偏移量,像素
|
||||
```
|
||||
|
||||
更新浮动图片的属性,`--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个;`--file` 与 `--src` 不能同时使用。
|
||||
更新浮动图片的属性,`--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个。
|
||||
`--float-image-id` 可通过 `list-float-images` 获取。
|
||||
|
||||
### 删除浮动图片
|
||||
@@ -221,16 +210,6 @@ dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B1" \
|
||||
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A2" \
|
||||
--values '[[{"type":"text","text":"MacBook Pro"}]]' -f json
|
||||
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json
|
||||
|
||||
# ── 工作流 11: 创建或替换浮动图片 ──
|
||||
|
||||
# 从本地图片直接创建
|
||||
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--file ./chart.png --range A1 --width 400 --height 300 -f json
|
||||
|
||||
# 从本地图片直接替换已有浮动图
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--float-image-id <FI_ID> --file ./replacement.png -f json
|
||||
```
|
||||
|
||||
## 上下文传递
|
||||
@@ -253,11 +232,11 @@ dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
- `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤
|
||||
- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象
|
||||
- `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格
|
||||
- `create-float-image --file` 可直接输入本地图片;仅在需要 `--name` / `--mime-type` 覆盖或复用既有资源时,先用 `media-upload` 获取 `resourceUrl` 再传 `--src`
|
||||
- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL
|
||||
- `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
|
||||
- `create-float-image` 的 `--width` / `--height` 为必填,单位像素,必须为正整数;`--offset-x` / `--offset-y` 可选,默认 0,不能为负数
|
||||
- `write-image`(单元格内嵌图片)vs `create-float-image`(浮动图片):`write-image` 将图片写入单元格内部,占据单元格内容;`create-float-image` 创建悬浮于单元格之上的浮动图片,不占用单元格内容,可自由调整位置和大小
|
||||
- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;优先直接传 `--file`
|
||||
- `update-float-image` 的 `--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个,且 `--file` 与 `--src` 不能同时使用
|
||||
- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;`--src` 必须来自 `media-upload` 的 `resourceUrl`
|
||||
- `update-float-image` 的 `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个
|
||||
- `list-float-images` 返回 `floatImages` 数组和 `totalCount`,每个元素包含 `id`(用于后续 get / update / delete)
|
||||
- `delete-float-image` 操作不可恢复,删除后图片将从工作表中移除
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
| 清空 / 排序 / 填充 / 复制移动区域 | `range clear` / `range sort` / `range fill` / `range copy-to` / `range move-to` | [sheet-range-operations](sheet/sheet-range-operations.md) | 用读写组合模拟服务端原子操作 |
|
||||
| 合并、冻结、分组、行高列宽 | `sheet info` + 结构命令 | [sheet-workbook](sheet/sheet-workbook.md)、[sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 从 `range read` / CSV 空值推断结构 |
|
||||
| 多个原子写操作组合 | `batch-update` | [sheet-batch-operations](sheet/sheet-batch-operations.md) | 多次独立调用导致半成品 |
|
||||
| 图片写入单元格 / 浮动图片 | `write-image` / `create-float-image --file` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 |
|
||||
| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 |
|
||||
| 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 |
|
||||
| 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 |
|
||||
| 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 |
|
||||
@@ -80,7 +80,7 @@
|
||||
| [sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 行列增删移动、属性设置与分组。当用户说"插入行/列"、"删除行/列"、"隐藏/显示行列"、"设行高/列宽"、"移动行/列"、"追加空行/空列"、"创建/取消行列分组"、"新建分组并设为折叠/展开"时使用。命令:`insert-dimension`/`delete-dimension`/`update-dimension`/`move-dimension`/`add-dimension`/`group-dimension`/`ungroup-dimension` |
|
||||
| [sheet-style-format](sheet/sheet-style-format.md) | 单元格样式与合并。当用户说"设样式"、"改颜色/字体/对齐"、"数字格式(百分比/货币/日期)"、"合并/取消合并"时使用。纯样式/批量样式走 `set-style`;写值同时设置少量 cell 样式可用 `range update` 的 `cellStyles`。命令:`range set-style`/`range batch-set-style`/`merge-cells`/`unmerge-cells` |
|
||||
| [sheet-dropdown](sheet/sheet-dropdown.md) | 下拉列表管理。当用户说"设置下拉"、"下拉选项"、"删除下拉"时使用。命令:`set-dropdown`/`get-dropdown`/`delete-dropdown` |
|
||||
| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片优先用 `create-float-image --file`,已有 resourceUrl 时用 `--src`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` |
|
||||
| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片需先 `media-upload` 再 `create-float-image`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` |
|
||||
| [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` |
|
||||
| [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` |
|
||||
| [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` |
|
||||
@@ -237,7 +237,7 @@ Flags:
|
||||
| "搜索公式文本" | `dws sheet find --node <nodeId或URL> --sheet-id <sheetId> --query "<公式片段>" --match-formula` |
|
||||
| "正则搜索 / 不区分大小写" | `dws sheet find --node <nodeId或URL> --sheet-id <sheetId> --query "<regexp>" --use-regexp --match-case=false` |
|
||||
| "插入图片到单元格" | `dws sheet write-image --node <nodeId或URL> --sheet-id <sheetId> --range A1 --file <图片路径>` |
|
||||
| "创建浮动图片" | `dws sheet create-float-image --node <nodeId或URL> --sheet-id <sheetId> --file <图片路径> --range A1 --width <宽> --height <高>` |
|
||||
| "创建浮动图片" | 先 `dws sheet media-upload --node <nodeId或URL> --file <图片路径>` 获取 `resourceUrl`,再 `dws sheet create-float-image --node <nodeId或URL> --sheet-id <sheetId> --src "<resourceUrl>" --range A1 --width <宽> --height <高>` |
|
||||
|
||||
## URL 与 ID 前置
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
### 浮动图片
|
||||
|
||||
用户说"浮动图片/悬浮图片/在表格上放一张图/加个浮动的图":
|
||||
- 创建浮动图片 → `create-float-image --file <本地图片>`;已有 `resourceUrl` 时可改用 `--src`
|
||||
- 创建浮动图片 → 先 `media-upload` 上传图片获取 `resourceUrl`,再 `create-float-image`
|
||||
- 浮动图片悬浮于单元格之上,不占用单元格内容,与 `write-image`(写入单元格内部的图片)不同
|
||||
|
||||
用户说"查看浮动图片/有哪些浮动图片/浮动图片列表":
|
||||
@@ -71,11 +71,7 @@ Flags:
|
||||
Usage:
|
||||
dws sheet create-float-image [flags]
|
||||
Example:
|
||||
# 直接上传本地图片并创建浮动图片
|
||||
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--file ./chart.png --range A1 --width 400 --height 300
|
||||
|
||||
# 高级用法:先上传图片获取 resourceUrl
|
||||
# 先上传图片获取 resourceUrl
|
||||
dws sheet media-upload --node <NODE_ID> --file ./chart.png
|
||||
# 输出: resourceUrl: /core/api/resources/img/xxxx...
|
||||
|
||||
@@ -89,8 +85,7 @@ Example:
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--file string 本地图片文件路径,与 --src 二选一
|
||||
--src string 图片资源路径,通过 media-upload 获取的 resourceUrl,与 --file 二选一
|
||||
--src string 图片资源路径,通过 media-upload 获取的 resourceUrl (必填)
|
||||
--range string 锚点单元格,A1 表示法,如 A1、B3 (必填)
|
||||
--width int 图片宽度,像素,正整数 (必填)
|
||||
--height int 图片高度,像素,正整数 (必填)
|
||||
@@ -99,8 +94,7 @@ Flags:
|
||||
```
|
||||
|
||||
浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。
|
||||
- `--file` 与 `--src` 必须且只能提供一个;`--file` 会在命令内完成凭证获取、文件上传和浮动图片创建
|
||||
- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL;需要自定义上传名称/MIME 时使用这个高级两步流程
|
||||
- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL
|
||||
- `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
|
||||
- `--width` / `--height` 为必填,单位像素,必须为正整数
|
||||
- `--offset-x` / `--offset-y` 表示相对锚点单元格左上角的偏移量(像素),默认 0,不能为负数
|
||||
@@ -144,18 +138,13 @@ Example:
|
||||
# 调整尺寸
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> --width 600 --height 400
|
||||
|
||||
# 直接用本地图片替换
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> \
|
||||
--file ./replacement.png
|
||||
|
||||
# 高级用法:通过已上传的 resourceUrl 替换
|
||||
# 替换图片(需先 media-upload 新图片获取 resourceUrl)
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> \
|
||||
--src "/core/api/resources/img/xxxx..."
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--float-image-id string 浮动图片 ID (必填)
|
||||
--file string 用于替换浮动图片的本地图片路径,与 --src 不能同时使用
|
||||
--src string 新的图片资源路径,通过 media-upload 获取的 resourceUrl
|
||||
--range string 新的锚点单元格,A1 表示法
|
||||
--width int 新的图片宽度,像素
|
||||
@@ -164,7 +153,7 @@ Flags:
|
||||
--offset-y int 新的垂直偏移量,像素
|
||||
```
|
||||
|
||||
更新浮动图片的属性,`--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个;`--file` 与 `--src` 不能同时使用。
|
||||
更新浮动图片的属性,`--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个。
|
||||
`--float-image-id` 可通过 `list-float-images` 获取。
|
||||
|
||||
### 删除浮动图片
|
||||
@@ -221,16 +210,6 @@ dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B1" \
|
||||
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A2" \
|
||||
--values '[[{"type":"text","text":"MacBook Pro"}]]' -f json
|
||||
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json
|
||||
|
||||
# ── 工作流 11: 创建或替换浮动图片 ──
|
||||
|
||||
# 从本地图片直接创建
|
||||
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--file ./chart.png --range A1 --width 400 --height 300 -f json
|
||||
|
||||
# 从本地图片直接替换已有浮动图
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--float-image-id <FI_ID> --file ./replacement.png -f json
|
||||
```
|
||||
|
||||
## 上下文传递
|
||||
@@ -253,11 +232,11 @@ dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
- `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤
|
||||
- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象
|
||||
- `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格
|
||||
- `create-float-image --file` 可直接输入本地图片;仅在需要 `--name` / `--mime-type` 覆盖或复用既有资源时,先用 `media-upload` 获取 `resourceUrl` 再传 `--src`
|
||||
- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL
|
||||
- `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
|
||||
- `create-float-image` 的 `--width` / `--height` 为必填,单位像素,必须为正整数;`--offset-x` / `--offset-y` 可选,默认 0,不能为负数
|
||||
- `write-image`(单元格内嵌图片)vs `create-float-image`(浮动图片):`write-image` 将图片写入单元格内部,占据单元格内容;`create-float-image` 创建悬浮于单元格之上的浮动图片,不占用单元格内容,可自由调整位置和大小
|
||||
- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;优先直接传 `--file`
|
||||
- `update-float-image` 的 `--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个,且 `--file` 与 `--src` 不能同时使用
|
||||
- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;`--src` 必须来自 `media-upload` 的 `resourceUrl`
|
||||
- `update-float-image` 的 `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个
|
||||
- `list-float-images` 返回 `floatImages` 数组和 `totalCount`,每个元素包含 `id`(用于后续 get / update / delete)
|
||||
- `delete-float-image` 操作不可恢复,删除后图片将从工作表中移除
|
||||
|
||||
Reference in New Issue
Block a user