Compare commits

..
Author SHA1 Message Date
赤川 65a7280805 Revert "feat(sheet): support local files for float images" 2026-08-24 22:56:51 +08:00
24 changed files with 195 additions and 2797 deletions
@@ -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.
-2
View File
@@ -54,8 +54,6 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
- **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.
### Changed
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.60-beta.2"
version "1.0.60-beta.1"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-arm64.tar.gz"
sha256 "e7776807f0664cbf0d0728cc236f2415c0981eb8d6557a897d2eeee708641b1d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-arm64.tar.gz"
sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-amd64.tar.gz"
sha256 "3004474df3cfb529719348f02c9f2f39afa88f0fca469fe8303a9ebe0f3a0034"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz"
sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-arm64.tar.gz"
sha256 "6386885d10f149c8c555031dda4cf07bf34e1e9daad61d4cd948b92d3c7b7bad"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz"
sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-amd64.tar.gz"
sha256 "5c94c2af269d2fe5a79a400d4fa3af267a86d6ab21b01a24ede1d29514a6eaef"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz"
sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-skills.zip"
sha256 "c3bd917f1b44a978ba2a9fbe95c5d0910ccf75f870f1c9b0dc356262ab1080c5"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip"
sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87"
end
def install
@@ -148,7 +148,7 @@ var paramAliasCompleteCommands = map[string][]string{
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
"contact +search-user": {"contact", "+search-user", "--query", "Fixture User"},
"contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"},
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1", "--fields", "name,userId"},
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"},
"dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"},
"devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"},
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
@@ -237,106 +237,52 @@ var paramAliasCompleteCommands = map[string][]string{
// param_concepts.json: inactive candidate templates are ignored, while every
// command becomes mandatory as soon as one of its reviewed aliases is active.
var paramAliasCandidateCompleteCommands = map[string][]string{
"aisearch": {"aisearch", "--query", "Fixture User", "--dimension", "name"},
"aisearch +search-person": {"aisearch", "+search-person", "--query", "Fixture User", "--dimensions", "name"},
"aisearch behavior": {"aisearch", "behavior", "--queries", "fixture", "--types", "im", "--behavior-type", "send", "--chat-scope", "Fixture Group", "--direction", "我->Fixture User", "--time-range", "本周"},
"aisearch enterprise": {"aisearch", "enterprise", "--queries", "fixture", "--types", "document", "--time-range", "本周"},
"aisearch person": {"aisearch", "person", "--query", "Fixture User", "--dimension", "name"},
"contact +by-mobile": {"contact", "+by-mobile", "--mobile", "13800138000"},
"contact +list-dept-members": {"contact", "+list-dept-members", "--depts", "1,2"},
"contact +list-followings": {"contact", "+list-followings", "--open-id", "open-fixture-1"},
"contact +list-role-members": {"contact", "+list-role-members", "--id", "12345"},
"contact +lookup": {"contact", "+lookup", "--name", "Fixture User"},
"contact +org": {"contact", "+org", "--name", "Fixture User"},
"contact +search-mobile": {"contact", "+search-mobile", "--mobile", "13800138000"},
"contact +team": {"contact", "+team", "--name", "Fixture User"},
"contact account create": {"contact", "account", "create", "--login-id", "fixture-login", "--org-user-name", "Fixture User", "--dept-ids", "1,2"},
"contact account update": {"contact", "account", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--avatar-file-id", "file-1", "--yes"},
"contact dept create": {"contact", "dept", "create", "--name", "Fixture Dept", "--parent", "1", "--create-dept-group", "--yes"},
"contact dept get-info": {"contact", "dept", "get-info", "--dept", "1"},
"contact dept list-members": {"contact", "dept", "list-members", "--depts", "1,2"},
"contact dept search": {"contact", "dept", "search", "--query", "Fixture Dept"},
"contact dept update": {"contact", "dept", "update", "--dept", "2", "--name", "Fixture Dept", "--parent", "1", "--yes"},
"contact label get": {"contact", "label", "get", "--names", "Fixture Role"},
"contact org create": {"contact", "org", "create", "--org-name", "Fixture Org", "--creator-username", "Fixture Creator"},
"contact user dismission search": {"contact", "user", "dismission", "search", "--depts", "1,2", "--start", "2026-03-01", "--end", "2026-03-31", "--page", "2", "--limit", "7"},
"contact user get": {"contact", "user", "get", "--ids", "user-1,user-2"},
"contact user invite": {"contact", "user", "invite", "--org-user-mobile", "13800138000", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`},
"contact user search": {"contact", "user", "search", "--query", "Fixture User"},
"contact user search-mobile": {"contact", "user", "search-mobile", "--mobile", "13800138000"},
"contact user update": {"contact", "user", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--yes"},
"contact user update-ownness": {"contact", "user", "update-ownness", "--user-id", "user-1", "--ownness-text", "Fixture Status", "--yes"},
"contact user update-self": {"contact", "user", "update-self", "--avatar-file-id", "file-1", "--nick", "Fixture Nick", "--yes"},
"devdoc +search-docs": {"devdoc", "+search-docs", "--query", "fixture", "--page", "2", "--size", "7"},
"hrbrain +get-pool": {"hrbrain", "+get-pool", "--pool-code", "pool-1"},
"hrbrain +list-pool-employees": {"hrbrain", "+list-pool-employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain +list-pools": {"hrbrain", "+list-pools", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
"hrbrain +profile-career": {"hrbrain", "+profile-career", "--work-no", "work-1"},
"hrbrain +profile-labels": {"hrbrain", "+profile-labels", "--staff-ids", "work-1,work-2", "--all-label"},
"hrbrain +profile-metadata": {"hrbrain", "+profile-metadata", "--work-no", "work-1"},
"hrbrain +profile-performance": {"hrbrain", "+profile-performance", "--work-no", "work-1"},
"hrbrain +query-profile": {"hrbrain", "+query-profile", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
"hrbrain +search-employees": {"hrbrain", "+search-employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain +search-employees-structured": {"hrbrain", "+search-employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
"hrbrain profile career": {"hrbrain", "profile", "career", "--work-no", "work-1"},
"hrbrain profile labels": {"hrbrain", "profile", "labels", "--staff-ids", "work-1,work-2", "--all-label"},
"hrbrain profile metadata": {"hrbrain", "profile", "metadata", "--work-no", "work-1"},
"hrbrain profile performance": {"hrbrain", "profile", "performance", "--work-no", "work-1"},
"hrbrain profile query": {"hrbrain", "profile", "query", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
"hrbrain search employees": {"hrbrain", "search", "employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain search employees-structured": {"hrbrain", "search", "employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
"hrbrain talent-pool detail": {"hrbrain", "talent-pool", "detail", "--pool-code", "pool-1"},
"hrbrain talent-pool employees": {"hrbrain", "talent-pool", "employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain talent-pool list": {"hrbrain", "talent-pool", "list", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
"pat +browser-policy": {"pat", "+browser-policy", "--enabled=false", "--agent-code", "fixture-agent", "--dry-run"},
"pat browser-policy": {"pat", "browser-policy", "--enabled=false", "--agentCode", "fixture-agent"},
"pat chmod": {"pat", "chmod", "--product", "calendar", "--products", "aitable", "--domain", "chat", "--domains", "mail", "--grant-type", "session", "--session-id", "session-1", "--recommend", "--agentCode", "fixture-agent", "--dry-run"},
"attendance +check-record": {"attendance", "+check-record", "--users", "user-1,user-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
"attendance +get-adjustment-rule": {"attendance", "+get-adjustment-rule", "--adjustment-id", "adjustment-1"},
"attendance +get-approve-template": {"attendance", "+get-approve-template", "--type", "leave"},
"attendance +get-checkin-record": {"attendance", "+get-checkin-record", "--operator-corp-id", "corp-1", "--operator-staff-id", "staff-operator", "--staff-ids", "staff-1,staff-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
"attendance +get-leave-records": {"attendance", "+get-leave-records", "--user", "user-1", "--start", "2026-03-01", "--end", "2026-03-31", "--leave-code", "annual_leave"},
"attendance +get-overtime-rule": {"attendance", "+get-overtime-rule", "--overtime-id", "overtime-1"},
"attendance +get-schedule": {"attendance", "+get-schedule", "--users", "user-1,user-2", "--start", "2026-03-10", "--end", "2026-03-11"},
"attendance +get-self-setting": {"attendance", "+get-self-setting", "--user", "user-1", "--setting-scene", "checkRemind"},
"attendance +get-summary": {"attendance", "+get-summary", "--user", "user-1", "--date", "2026-03-10", "--stats-type", "week"},
"attendance +list-approve": {"attendance", "+list-approve", "--users", "user-1,user-2", "--types", "leave", "--start", "2026-03-01", "--end", "2026-03-31"},
"attendance +query-report-data": {"attendance", "+query-report-data", "--users", "user-1,user-2", "--columns", "attendance_days,late_count", "--start", "2026-03-01", "--end", "2026-03-31"},
"attendance +search-adjustment-rule": {"attendance", "+search-adjustment-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
"attendance +search-class": {"attendance", "+search-class", "--filter-type", "name", "--query", "fixture"},
"attendance +search-group": {"attendance", "+search-group", "--type", "FIXED"},
"attendance +search-overtime-rule": {"attendance", "+search-overtime-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
"ding +list": {"ding", "+list", "--cursor", "0", "--type", "ALL"},
"ding +recall-personal": {"ding", "+recall-personal", "--id", "ding-1", "--yes"},
"ding +send-personal": {"ding", "+send-personal", "--users", appFixtureCurrentDOpenID, "--content", "fixture", "--yes"},
"mail +contact-list": {"mail", "+contact-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
"mail +folder-list": {"mail", "+folder-list", "--email", "fixture@example.com", "--folder", "folder-1"},
"mail +message": {"mail", "+message", "--email", "fixture@example.com", "--id", "message-1"},
"mail +messages": {"mail", "+messages", "--email", "fixture@example.com", "--ids", "message-1,message-2"},
"mail +recent-mail": {"mail", "+recent-mail", "--limit", "7", "--cursor", "cursor-1"},
"mail +search-mail": {"mail", "+search-mail", "--query", "fixture", "--size", "7", "--cursor", "cursor-1"},
"mail +template-list": {"mail", "+template-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
"mail +thread": {"mail", "+thread", "--email", "fixture@example.com", "--id", "thread-1"},
"mail +thread-list": {"mail", "+thread-list", "--email", "fixture@example.com", "--folder", "folder-1", "--cursor", "cursor-1"},
"mail +triage": {"mail", "+triage", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
"mail +unread-mail": {"mail", "+unread-mail", "--size", "7", "--cursor", "cursor-1"},
"mail +user-search": {"mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"},
"markdown create": {"markdown", "create", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1"},
"markdown diff": {"markdown", "diff", "--node", "node-1", "--version", "1", "--version2", "2", "--context", "3"},
"markdown fetch": {"markdown", "fetch", "--node", "node-1", "--space-id", "space-1", "--output", "/tmp/dws-markdown-fixture.md"},
"markdown overwrite": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
"markdown patch": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--space-id", "space-1", "--yes"},
"oa +list-cc": {"oa", "+list-cc", "--page", "2"},
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "2"},
"oa +list-forms": {"oa", "+list-forms", "--cursor", "2"},
"oa +list-pending": {"oa", "+list-pending", "--start", "1773072000000", "--end", "1773158399000", "--page", "2"},
"oa +list-submitted": {"oa", "+list-submitted", "--page", "2"},
"oa +my-initiated": {"oa", "+my-initiated", "--page", "2"},
"report +outbox-list": {"report", "+outbox-list", "--size", "7"},
"report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"},
"report +template-search": {"report", "+template-search", "--query", "fixture"},
"sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"},
"sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"},
"attendance +check-record": {"attendance", "+check-record", "--users", "user-1,user-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
"attendance +get-adjustment-rule": {"attendance", "+get-adjustment-rule", "--adjustment-id", "adjustment-1"},
"attendance +get-approve-template": {"attendance", "+get-approve-template", "--type", "leave"},
"attendance +get-checkin-record": {"attendance", "+get-checkin-record", "--operator-corp-id", "corp-1", "--operator-staff-id", "staff-operator", "--staff-ids", "staff-1,staff-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
"attendance +get-leave-records": {"attendance", "+get-leave-records", "--user", "user-1", "--start", "2026-03-01", "--end", "2026-03-31", "--leave-code", "annual_leave"},
"attendance +get-overtime-rule": {"attendance", "+get-overtime-rule", "--overtime-id", "overtime-1"},
"attendance +get-schedule": {"attendance", "+get-schedule", "--users", "user-1,user-2", "--start", "2026-03-10", "--end", "2026-03-11"},
"attendance +get-self-setting": {"attendance", "+get-self-setting", "--user", "user-1", "--setting-scene", "checkRemind"},
"attendance +get-summary": {"attendance", "+get-summary", "--user", "user-1", "--date", "2026-03-10", "--stats-type", "week"},
"attendance +list-approve": {"attendance", "+list-approve", "--users", "user-1,user-2", "--types", "leave", "--start", "2026-03-01", "--end", "2026-03-31"},
"attendance +query-report-data": {"attendance", "+query-report-data", "--users", "user-1,user-2", "--columns", "attendance_days,late_count", "--start", "2026-03-01", "--end", "2026-03-31"},
"attendance +search-adjustment-rule": {"attendance", "+search-adjustment-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
"attendance +search-class": {"attendance", "+search-class", "--filter-type", "name", "--query", "fixture"},
"attendance +search-group": {"attendance", "+search-group", "--type", "FIXED"},
"attendance +search-overtime-rule": {"attendance", "+search-overtime-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
"ding +list": {"ding", "+list", "--cursor", "0", "--type", "ALL"},
"ding +recall-personal": {"ding", "+recall-personal", "--id", "ding-1", "--yes"},
"ding +send-personal": {"ding", "+send-personal", "--users", appFixtureCurrentDOpenID, "--content", "fixture", "--yes"},
"mail +contact-list": {"mail", "+contact-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
"mail +folder-list": {"mail", "+folder-list", "--email", "fixture@example.com", "--folder", "folder-1"},
"mail +message": {"mail", "+message", "--email", "fixture@example.com", "--id", "message-1"},
"mail +messages": {"mail", "+messages", "--email", "fixture@example.com", "--ids", "message-1,message-2"},
"mail +recent-mail": {"mail", "+recent-mail", "--limit", "7", "--cursor", "cursor-1"},
"mail +search-mail": {"mail", "+search-mail", "--query", "fixture", "--size", "7", "--cursor", "cursor-1"},
"mail +template-list": {"mail", "+template-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
"mail +thread": {"mail", "+thread", "--email", "fixture@example.com", "--id", "thread-1"},
"mail +thread-list": {"mail", "+thread-list", "--email", "fixture@example.com", "--folder", "folder-1", "--cursor", "cursor-1"},
"mail +triage": {"mail", "+triage", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
"mail +unread-mail": {"mail", "+unread-mail", "--size", "7", "--cursor", "cursor-1"},
"mail +user-search": {"mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"},
"markdown create": {"markdown", "create", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1"},
"markdown diff": {"markdown", "diff", "--node", "node-1", "--version", "1", "--version2", "2", "--context", "3"},
"markdown fetch": {"markdown", "fetch", "--node", "node-1", "--space-id", "space-1", "--output", "/tmp/dws-markdown-fixture.md"},
"markdown overwrite": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
"markdown patch": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--space-id", "space-1", "--yes"},
"oa +list-cc": {"oa", "+list-cc", "--page", "2"},
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "2"},
"oa +list-forms": {"oa", "+list-forms", "--cursor", "2"},
"oa +list-pending": {"oa", "+list-pending", "--start", "1773072000000", "--end", "1773158399000", "--page", "2"},
"oa +list-submitted": {"oa", "+list-submitted", "--page", "2"},
"oa +my-initiated": {"oa", "+my-initiated", "--page", "2"},
"report +outbox-list": {"report", "+outbox-list", "--size", "7"},
"report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"},
"report +template-search": {"report", "+template-search", "--query", "fixture"},
"sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"},
"sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"},
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
@@ -1084,14 +1030,14 @@ func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string)
}
}
// TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation
// TestCrossPlatformCoverageEightProductTemplatedParamAliasesCannotBypassConfirmation
// exercises every distinct reviewed mutating complete-command template in the
// reviewed product expansions. The fixture gate already proves every
// eight-product Shortcut expansion. The fixture gate already proves every
// alias resolves through PreParse; this gate removes the confirmation flag
// from one active alias invocation per distinct template and requires the
// runtime boundary to stop it before the first transport call. An explicit
// --dry-run is a reviewed preview path and must not carry a bypass flag.
func TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation(t *testing.T) {
func TestCrossPlatformCoverageEightProductTemplatedParamAliasesCannotBypassConfirmation(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
@@ -1105,8 +1051,7 @@ func TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassCo
}
product, _, _ := strings.Cut(fixture.Command, " ")
switch product {
case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown",
"aisearch", "contact", "live", "devdoc", "hrbrain", "pat":
case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown":
default:
continue
}
-2
View File
@@ -740,7 +740,6 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
"mcp_tool_error",
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
invocation.CanonicalProduct,
invocation.Tool,
diag,
)
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
@@ -766,7 +765,6 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
"business_error",
"The API returned a business-level error. Check required parameters and values.",
invocation.CanonicalProduct,
invocation.Tool,
diag,
)
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
@@ -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)
}
}
}
+8 -47
View File
@@ -20,50 +20,18 @@ import (
)
type serverFailureClass struct {
message string
reason string
origin string
stage string
hint string
actions []string
operation string
retryable *bool
message string
reason string
origin string
stage string
hint string
actions []string
}
func classifyServerFailure(message, serverKey, tool string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
text := strings.ToLower(strings.TrimSpace(message))
combined := text + " " + detail
if code == "999" &&
(strings.Contains(combined, "nullpointerexception") || strings.Contains(combined, "system error")) {
classified := serverFailureClass{
message: message,
reason: "upstream_internal_error",
origin: "dingtalk_api",
stage: "upstream_execution",
hint: "上游服务发生内部异常;请保留 Trace ID 和 Server Code,确认操作结果后再决定是否重试。",
actions: []string{
"检查目标资源的当前状态,确认本次操作是否已经生效",
"状态未确认前不要直接重试写操作",
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
},
}
if strings.EqualFold(strings.TrimSpace(serverKey), "todo") &&
strings.EqualFold(strings.TrimSpace(tool), "create_personal_todo") {
retryable := false
classified.operation = "todo/create_personal_todo"
classified.retryable = &retryable
classified.hint = "待办服务发生内部异常,创建结果未知;请先查询是否已创建相同待办,再决定是否重试。"
classified.actions = []string{
"查询近期由自己创建的待办,核对标题、执行人和截止时间",
"确认没有创建成功后再重新提交",
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
}
}
return classified, true
}
if code == "NETWORK_ERROR" ||
strings.Contains(detail, "statuscode.unavailable") ||
@@ -106,7 +74,6 @@ func newServerFailureAPIError(
fallbackReason string,
fallbackHint string,
serverKey string,
tool string,
diag apperrors.ServerDiagnostics,
) error {
opts := []apperrors.Option{
@@ -117,7 +84,7 @@ func newServerFailureAPIError(
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
apperrors.WithServerDiag(diag),
}
if classified, ok := classifyServerFailure(message, serverKey, tool, diag); ok {
if classified, ok := classifyServerFailure(message, diag); ok {
message = classified.message
opts = append(opts,
apperrors.WithReason(classified.reason),
@@ -126,12 +93,6 @@ func newServerFailureAPIError(
apperrors.WithHint(classified.hint),
apperrors.WithActions(classified.actions...),
)
if classified.operation != "" {
opts = append(opts, apperrors.WithOperation(classified.operation))
}
if classified.retryable != nil {
opts = append(opts, apperrors.WithRetryable(*classified.retryable))
}
}
return apperrors.NewAPI(message, opts...)
}
@@ -34,7 +34,6 @@ func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{
TraceID: "trace-local",
ServerErrorCode: "NETWORK_ERROR",
@@ -67,7 +66,6 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
"business_error",
"check parameters",
"chat",
"send_message",
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
)
var typed *apperrors.Error
@@ -82,74 +80,12 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
}
}
func TestCrossPlatformCoverageServerFailureClassifierTodoCreateUpstreamInternalError(t *testing.T) {
serverSaysRetryable := true
err := newServerFailureAPIError(
"[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
"business_error",
"The API returned a business-level error. Check required parameters and values.",
"todo",
"create_personal_todo",
apperrors.ServerDiagnostics{
TraceID: "trace-todo-create",
ServerErrorCode: "999",
ServerRetryable: &serverSaysRetryable,
},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "upstream_internal_error" || typed.Origin != "dingtalk_api" || typed.FailureStage != "upstream_execution" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.Operation != "todo/create_personal_todo" {
t.Fatalf("operation = %q, want todo/create_personal_todo", typed.Operation)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown", typed.ExecutionStarted)
}
if !typed.RetryableSet || typed.Retryable {
t.Fatalf("retryability = (%v, %v), want explicit false", typed.RetryableSet, typed.Retryable)
}
if typed.ServerDiag.TraceID != "trace-todo-create" || typed.ServerDiag.ServerErrorCode != "999" {
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
}
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || !strings.Contains(typed.Hint, "创建结果未知") {
t.Fatalf("hint = %q", typed.Hint)
}
for _, action := range typed.Actions {
if strings.Contains(action, "dws doctor") || strings.Contains(action, "登录") || strings.Contains(action, "网络") {
t.Fatalf("misleading action = %q", action)
}
}
payload := multiProfileErrorPayload(err)
for key, want := range map[string]any{
"reason": "upstream_internal_error",
"origin": "dingtalk_api",
"stage": "upstream_execution",
"retryable": false,
"trace_id": "trace-todo-create",
"server_error_code": "999",
} {
if got := payload[key]; got != want {
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
}
}
if _, ok := payload["execution_started"]; ok {
t.Fatalf("payload must keep execution_started unknown: %#v", payload)
}
}
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{},
)
var typed *apperrors.Error
@@ -170,7 +106,6 @@ func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *test
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
)
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
@@ -188,7 +123,6 @@ func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{
TraceID: "trace-multi",
ServerErrorCode: "NETWORK_ERROR",
@@ -290,58 +224,3 @@ func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFail
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
}
}
func TestCrossPlatformCoverageExecuteInvocationClassifiesTodoCreateUpstreamInternalError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID int `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": map[string]any{
"structuredContent": map[string]any{
"success": false,
"code": "999",
"trace_id": "trace-todo-replay",
"errorMsg": "[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
},
},
})
}))
defer server.Close()
client := transport.NewClient(server.Client())
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
runner := &runtimeRunner{
transport: client,
globalFlags: &GlobalFlags{Token: "local-test-token"},
}
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: "todo",
Tool: "create_personal_todo",
CanonicalPath: "todo.create_personal_todo",
Params: map[string]any{
"PersonalTodoCreateVO": map[string]any{
"subject": "fixture",
"executorIds": []string{"user-1"},
},
},
})
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
}
if typed.Reason != "upstream_internal_error" || typed.Operation != "todo/create_personal_todo" {
t.Fatalf("classification = reason %q operation %q", typed.Reason, typed.Operation)
}
if !typed.RetryableSet || typed.Retryable || typed.ExecutionStarted != nil {
t.Fatalf("failure semantics = retryable(%v,%v) execution_started=%v", typed.RetryableSet, typed.Retryable, typed.ExecutionStarted)
}
if typed.ServerDiag.TraceID != "trace-todo-replay" || typed.ServerDiag.ServerErrorCode != "999" {
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
}
}
+17 -591
View File
@@ -10,102 +10,6 @@ package cli
// generatedParamAliases is the per-command parameter-alias table reduced from
// the reviewed concept dictionary. Each entry binds one runnable Cobra leaf.
var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "aisearch",
Aliases: map[string]string{
"dimensions": "dimension",
"person-dimension": "dimension",
"person-name": "query",
"person-query": "query",
"search-by": "dimension",
"search-dimension": "dimension",
"search-term": "query",
},
Blocked: []string{"action-type", "behavior-type", "category", "chat-scope", "department-id", "direction", "employee-number", "field", "job-number", "mobile", "open-dingtalk-id", "phone-number", "queries", "search-types", "time-range", "types", "user-id"},
Ambiguous: []string{"department", "duty", "employee", "id", "phone", "position", "scope", "target"},
},
{
CLIPath: "aisearch +search-person",
Aliases: map[string]string{
"dimension": "dimensions",
"keyword": "query",
"keywords": "query",
"person-dimension": "dimensions",
"person-name": "query",
"person-query": "query",
"q": "query",
"search-by": "dimensions",
"search-dimension": "dimensions",
"search-term": "query",
"search-word": "query",
},
Blocked: []string{"action-type", "behavior-type", "category", "chat-scope", "department-id", "direction", "employee-number", "field", "ids", "job-number", "mobile", "name", "open-dingtalk-id", "phone-number", "position-id", "queries", "search-types", "subject", "text", "time-range", "title", "type", "types", "user-id"},
Ambiguous: []string{"department", "duty", "employee", "id", "phone", "position", "scope", "target"},
},
{
CLIPath: "aisearch behavior",
Aliases: map[string]string{
"action-type": "behavior-type",
"activity-type": "behavior-type",
"behavior-action": "behavior-type",
"chat-name": "chat-scope",
"content-queries": "queries",
"content-query": "queries",
"content-types": "types",
"conversation-name": "chat-scope",
"date-range": "time-range",
"group-name": "chat-scope",
"im-scope": "chat-scope",
"interaction-direction": "direction",
"interaction-flow": "direction",
"message-flow": "direction",
"period": "time-range",
"resource-types": "types",
"search-categories": "types",
"search-period": "time-range",
"search-terms": "queries",
"search-type-list": "types",
"time-window": "time-range",
"topic-queries": "queries",
"topics": "queries",
},
Blocked: []string{"chat-id", "conversation-id", "created-at", "dimension", "dimensions", "document-id", "end", "file-id", "from", "group-id", "natural-language", "node-id", "open-conversation-id", "open-dingtalk-id", "question", "receiver", "sender", "since", "start", "to", "until", "update", "updated-at", "user-id", "write"},
Ambiguous: []string{"action", "actor", "category", "chat", "conversation", "date", "destination", "group", "range", "scope", "source", "target", "time", "type"},
},
{
CLIPath: "aisearch enterprise",
Aliases: map[string]string{
"content-queries": "queries",
"content-query": "queries",
"content-types": "types",
"date-range": "time-range",
"period": "time-range",
"resource-types": "types",
"search-categories": "types",
"search-period": "time-range",
"search-terms": "queries",
"search-type-list": "types",
"time-window": "time-range",
"topic-queries": "queries",
"topics": "queries",
},
Blocked: []string{"action-type", "behavior-type", "chat-scope", "created-at", "dimension", "dimensions", "direction", "document-id", "end", "file-id", "from", "group-name", "node-id", "open-dingtalk-id", "receiver", "sender", "since", "start", "to", "until", "update", "updated-at", "user-id", "write"},
Ambiguous: []string{"category", "date", "natural-language", "question", "range", "scope", "source", "time", "type"},
},
{
CLIPath: "aisearch person",
Aliases: map[string]string{
"dimensions": "dimension",
"person-dimension": "dimension",
"person-name": "query",
"person-query": "query",
"search-by": "dimension",
"search-dimension": "dimension",
"search-term": "query",
},
Blocked: []string{"action-type", "behavior-type", "category", "chat-scope", "department-id", "direction", "employee-number", "field", "ids", "job-number", "mobile", "open-dingtalk-id", "phone-number", "position-id", "queries", "search-types", "time-range", "types", "user-id"},
Ambiguous: []string{"department", "duty", "employee", "id", "phone", "position", "scope", "target"},
},
{
CLIPath: "aitable +advperm-disable",
Aliases: map[string]string{
@@ -4224,13 +4128,6 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "contact +by-mobile",
Aliases: map[string]string{
"org-user-mobile": "mobile",
},
Blocked: []string{"email", "login-id", "user-id"},
},
{
CLIPath: "contact +dept-members",
Aliases: map[string]string{
@@ -4251,23 +4148,6 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"department-id", "dept", "dept-id", "name", "query"},
},
{
CLIPath: "contact +list-followings",
Aliases: map[string]string{
"open-dingtalk-id": "open-id",
"open-dingtalkid": "open-id",
},
Blocked: []string{"email", "mobile", "name", "open-dingtalk-ids", "phone", "query", "staff-id", "user-id", "userid"},
Ambiguous: []string{"id", "person", "user"},
},
{
CLIPath: "contact +list-role-members",
Aliases: map[string]string{
"label-id": "id",
"role-id": "id",
},
Blocked: []string{"dept", "dept-id", "ids", "names", "staff-id", "user-id"},
},
{
CLIPath: "contact +list-sub-depts",
Aliases: map[string]string{
@@ -4279,28 +4159,6 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"department-ids", "dept-ids", "depts", "name", "query"},
},
{
CLIPath: "contact +lookup",
Aliases: map[string]string{
"employee-name": "name",
"flower-name": "name",
"person-name": "name",
"staff-name": "name",
"user-name": "name",
},
Blocked: []string{"department-id", "dept", "dept-id", "org-name", "role-name"},
},
{
CLIPath: "contact +org",
Aliases: map[string]string{
"employee-name": "name",
"flower-name": "name",
"person-name": "name",
"staff-name": "name",
"user-name": "name",
},
Blocked: []string{"department-id", "dept", "dept-id", "org-name", "role-name"},
},
{
CLIPath: "contact +resolve-dept",
Aliases: map[string]string{
@@ -4312,13 +4170,6 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"subject", "text", "title"},
},
{
CLIPath: "contact +search-mobile",
Aliases: map[string]string{
"org-user-mobile": "mobile",
},
Blocked: []string{"email", "login-id", "user-id"},
},
{
CLIPath: "contact +search-user",
Aliases: map[string]string{
@@ -4329,65 +4180,6 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"name", "subject", "text", "title"},
},
{
CLIPath: "contact +team",
Aliases: map[string]string{
"employee-name": "name",
"flower-name": "name",
"person-name": "name",
"staff-name": "name",
"user-name": "name",
},
Blocked: []string{"department-id", "dept", "dept-id", "org-name", "role-name"},
},
{
CLIPath: "contact account create",
Aliases: map[string]string{
"account-login-id": "login-id",
"department-ids": "dept-ids",
"depts": "dept-ids",
"email-address": "email",
"employee-name": "org-user-name",
"login-account": "login-id",
"mail-address": "email",
"mobile": "org-user-mobile",
"organization-user-name": "org-user-name",
"staff-name": "org-user-name",
},
Blocked: []string{"creator-username", "department-id", "department-memberships-json", "dept", "dept-id", "depts-json", "name", "nick", "org-name", "query", "staff-id", "user-id"},
},
{
CLIPath: "contact account update",
Aliases: map[string]string{
"department-assignments-json": "depts",
"department-memberships-json": "depts",
"employee-name": "org-user-name",
"organization-user-name": "org-user-name",
"profile-image-file-id": "avatar-file-id",
"staff-id": "user-id",
"staff-name": "org-user-name",
"uid": "user-id",
"user": "user-id",
},
Blocked: []string{"at-user-ids", "creator-username", "department-id", "department-ids", "dept", "dept-id", "dept-ids", "file", "name", "node-id", "org-name", "ownness-text", "parent", "parent-id", "to-user", "user-ids", "users"},
Ambiguous: []string{"manager", "owner-id"},
},
{
CLIPath: "contact dept create",
Aliases: map[string]string{
"new-parent-dept-id": "parent",
"parent-department-id": "parent",
"parent-dept-id": "parent",
},
Blocked: []string{"department-ids", "dept", "dept-id", "dept-ids", "depts"},
},
{
CLIPath: "contact dept get-info",
Aliases: map[string]string{
"department-id": "dept",
},
Blocked: []string{"department", "department-ids", "depts", "parent", "parent-id"},
},
{
CLIPath: "contact dept list-children",
Aliases: map[string]string{
@@ -4398,180 +4190,18 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"department-ids", "depts", "name", "query"},
},
{
CLIPath: "contact dept list-members",
Aliases: map[string]string{
"department-ids": "depts",
},
Blocked: []string{"department-id", "dept", "name", "query"},
},
{
CLIPath: "contact dept search",
Aliases: map[string]string{
"keywords": "query",
"q": "query",
"search-word": "query",
},
Blocked: []string{"subject", "text", "title"},
},
{
CLIPath: "contact dept update",
Aliases: map[string]string{
"new-parent-dept-id": "parent",
"parent-department-id": "parent",
"parent-dept-id": "parent",
"target-department-id": "dept",
"target-dept-id": "dept",
},
Blocked: []string{"department-ids", "depts"},
Ambiguous: []string{"department", "target-id"},
},
{
CLIPath: "contact label get",
Aliases: map[string]string{
"label-names": "names",
"role-names": "names",
},
Blocked: []string{"dept-id", "id", "label-id", "role-id", "user-id"},
},
{
CLIPath: "contact label list-members",
Blocked: []string{"dept", "dept-id", "ids", "names", "staff-id", "user-id"},
},
{
CLIPath: "contact org create",
Aliases: map[string]string{
"company-name": "org-name",
"corp-name": "org-name",
"creator-name": "creator-username",
"founder-name": "creator-username",
"organization-name": "org-name",
},
Blocked: []string{"login-id", "user-id"},
Ambiguous: []string{"name"},
},
{
CLIPath: "contact user dismission search",
Aliases: map[string]string{
"begin": "start",
"current-page": "page",
"department-ids": "depts",
"dept-ids": "depts",
"employee-name": "name",
"end-date": "end",
"end-time": "end",
"flower-name": "name",
"from": "start",
"from-date": "start",
"max-result": "limit",
"max-results": "limit",
"max-time": "end",
"min-time": "start",
"page-no": "page",
"page-num": "page",
"page-size": "limit",
"per-page": "limit",
"person-name": "name",
"since": "start",
"size": "limit",
"staff-name": "name",
"start-date": "start",
"start-time": "start",
"take": "limit",
"time-max": "end",
"time-min": "start",
"top": "limit",
"user-name": "name",
},
Blocked: []string{"count", "cursor", "date", "department-id", "department-memberships-json", "dept", "dept-id", "dept-list-json", "offset", "page-index", "page-token", "query", "time"},
},
{
CLIPath: "contact user get",
Aliases: map[string]string{
"member-user-ids": "ids",
"staff-ids": "ids",
"users": "ids",
},
Blocked: []string{"at-user-ids", "staff-id", "uid", "user"},
},
{
CLIPath: "contact user invite",
Aliases: map[string]string{
"department-assignments-json": "depts",
"department-memberships-json": "depts",
"employee-name": "org-user-name",
"mobile": "org-user-mobile",
"organization-user-name": "org-user-name",
"staff-name": "org-user-name",
},
Blocked: []string{"creator-username", "department-id", "department-ids", "dept", "dept-id", "dept-ids", "email", "login-id", "nick", "org-name", "parent", "parent-id", "user-id"},
},
{
CLIPath: "contact user profile get",
Aliases: map[string]string{
"field-code-list": "fields",
"field-codes": "fields",
"id": "staff-id",
"ids": "staff-id",
"profile-fields": "fields",
"roster-fields": "fields",
"uid": "staff-id",
"user": "staff-id",
"user-id": "staff-id",
"userid": "staff-id",
},
Blocked: []string{"at-user-ids", "name", "output-fields", "staff-ids", "to-user", "user-ids", "users"},
},
{
CLIPath: "contact user search",
Aliases: map[string]string{
"keywords": "query",
"q": "query",
"search-word": "query",
},
Blocked: []string{"subject", "text", "title"},
},
{
CLIPath: "contact user search-mobile",
Aliases: map[string]string{
"org-user-mobile": "mobile",
},
Blocked: []string{"email", "login-id", "user-id"},
},
{
CLIPath: "contact user update",
Aliases: map[string]string{
"department-assignments-json": "depts",
"department-memberships-json": "depts",
"employee-name": "org-user-name",
"organization-user-name": "org-user-name",
"staff-id": "user-id",
"staff-name": "org-user-name",
"uid": "user-id",
"user": "user-id",
},
Blocked: []string{"at-user-ids", "creator-username", "department-id", "department-ids", "dept", "dept-id", "dept-ids", "name", "nick", "org-name", "parent", "parent-id", "to-user", "user-ids", "users"},
Ambiguous: []string{"manager", "owner-id"},
},
{
CLIPath: "contact user update-ownness",
Aliases: map[string]string{
"personal-status": "ownness-text",
"staff-id": "user-id",
"status": "ownness-text",
"status-text": "ownness-text",
"uid": "user-id",
"user": "user-id",
"id": "staff-id",
"ids": "staff-id",
"uid": "staff-id",
"user": "staff-id",
"user-id": "staff-id",
"userid": "staff-id",
},
Blocked: []string{"at-user-ids", "name", "to-user", "user-ids", "users"},
},
{
CLIPath: "contact user update-self",
Aliases: map[string]string{
"profile-image-file-id": "avatar-file-id",
},
Blocked: []string{"file", "name", "node-id", "org-user-name", "ownness-text", "user-id"},
},
{
CLIPath: "dev app get",
Aliases: map[string]string{
@@ -4583,26 +4213,18 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "devdoc article search",
Aliases: map[string]string{
"current-page": "page",
"keywords": "query",
"limit": "size",
"max-result": "size",
"max-results": "size",
"page-no": "page",
"page-num": "page",
"page-number": "page",
"page-size": "size",
"per-page": "size",
"q": "query",
"query-text": "query",
"results-per-page": "size",
"search-term": "query",
"search-word": "query",
"take": "size",
"top": "size",
"current-page": "page",
"limit": "size",
"max-result": "size",
"max-results": "size",
"page-no": "page",
"page-num": "page",
"page-size": "size",
"per-page": "size",
"take": "size",
"top": "size",
},
Blocked: []string{"context", "count", "cursor", "doc-id", "document-id", "error-code", "error-message", "file-id", "name", "next-cursor", "next-token", "node-id", "offset", "page-index", "page-token", "request-id", "space-id", "subject", "text", "title", "total", "trace-id", "workspace-id"},
Ambiguous: []string{"id", "type"},
Blocked: []string{"count", "cursor", "page-index", "page-token"},
},
{
CLIPath: "ding +list",
@@ -6730,166 +6352,6 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"knowledge-base-id", "limit", "page-size", "part-size", "size", "space", "wiki-workspace-id", "workspace", "workspace-id"},
},
{
CLIPath: "hrbrain profile career",
Aliases: map[string]string{
"employee-no": "work-no",
"employee-number": "work-no",
"staff-no": "work-no",
},
Blocked: []string{"employee-id", "end", "end-date", "job-level", "position", "staff-id", "staff-ids", "start", "start-date", "user-id", "work-nos", "year"},
Ambiguous: []string{"date", "employee", "id", "name", "time", "user"},
},
{
CLIPath: "hrbrain profile labels",
Aliases: map[string]string{
"all-labels": "all-label",
"employee-nos": "staff-ids",
"staff-nos": "staff-ids",
"work-nos": "staff-ids",
},
Blocked: []string{"employee-id", "employee-ids", "employee-no", "staff-id", "staff-no", "user-id", "user-ids", "work-no"},
Ambiguous: []string{"employee", "id", "label", "name", "tag", "user"},
},
{
CLIPath: "hrbrain profile metadata",
Aliases: map[string]string{
"employee-no": "work-no",
"employee-number": "work-no",
"staff-no": "work-no",
},
Blocked: []string{"data-queries", "employee-id", "model-code", "module", "section", "staff-id", "staff-ids", "user-id", "work-nos"},
Ambiguous: []string{"employee", "field", "id", "name", "user"},
},
{
CLIPath: "hrbrain profile performance",
Aliases: map[string]string{
"employee-no": "work-no",
"employee-number": "work-no",
"staff-no": "work-no",
},
Blocked: []string{"cycle", "employee-id", "end", "end-date", "rating", "score", "staff-id", "staff-ids", "start", "start-date", "user-id", "work-nos", "year"},
Ambiguous: []string{"date", "employee", "id", "level", "name", "time", "user"},
},
{
CLIPath: "hrbrain profile query",
Aliases: map[string]string{
"employee-no": "work-no",
"employee-number": "work-no",
"module-queries": "data-queries",
"profile-queries": "data-queries",
"staff-no": "work-no",
},
Blocked: []string{"data-query", "employee-id", "model-code", "module", "section", "staff-id", "staff-ids", "user-id", "work-nos"},
Ambiguous: []string{"employee", "field", "id", "name", "query", "user"},
},
{
CLIPath: "hrbrain search employees",
Aliases: map[string]string{
"current-page": "page",
"department-name": "dept-name",
"job-title": "position-name",
"keywords": "keyword",
"limit": "page-size",
"max-result": "page-size",
"max-results": "page-size",
"page-no": "page",
"page-num": "page",
"per-page": "page-size",
"position-level": "job-level",
"q": "keyword",
"query": "keyword",
"search-word": "keyword",
"size": "page-size",
"take": "page-size",
"talent-pool-code": "pool-code",
"top": "page-size",
},
Blocked: []string{"count", "cursor", "data-queries", "department-id", "dept-id", "employee-id", "order-by", "origin-json", "page-index", "page-token", "pool-id", "staff-id", "staff-ids", "subject", "talent-pool-id", "text", "user-id", "work-no"},
Ambiguous: []string{"code", "department", "grade", "id", "level", "name", "pool", "position", "title", "user"},
},
{
CLIPath: "hrbrain search employees-structured",
Aliases: map[string]string{
"criteria-json": "origin-json",
"current-page": "page",
"filter-json": "origin-json",
"limit": "page-size",
"max-result": "page-size",
"max-results": "page-size",
"page-no": "page",
"page-num": "page",
"per-page": "page-size",
"result-fields-json": "fields",
"return-fields-json": "fields",
"search-json": "origin-json",
"size": "page-size",
"sort-by": "order-by",
"sort-fields": "order-by",
"take": "page-size",
"top": "page-size",
},
Blocked: []string{"count", "cursor", "department-name", "dept-name", "field-name", "job-level", "keyword", "operator", "operators", "page-index", "page-token", "pool-code", "position-name", "query", "staff-ids", "work-no"},
Ambiguous: []string{"criteria", "field", "filter", "id", "name", "order", "sort", "type"},
},
{
CLIPath: "hrbrain search fields",
Blocked: []string{"dept-name", "field-name", "job-level", "keyword", "operator", "operators", "order-by", "origin-json", "page", "page-size", "pool-code", "position-name", "query", "staff-ids", "work-no"},
Ambiguous: []string{"criteria", "field", "filter", "id", "name", "type"},
},
{
CLIPath: "hrbrain talent-pool detail",
Aliases: map[string]string{
"talent-pool-code": "pool-code",
},
Blocked: []string{"creator", "employee-id", "keyword", "labels", "page", "page-size", "pool-id", "query", "staff-ids", "talent-pool-id", "work-no"},
Ambiguous: []string{"code", "id", "name", "pool", "type"},
},
{
CLIPath: "hrbrain talent-pool employees",
Aliases: map[string]string{
"current-page": "page",
"limit": "page-size",
"max-result": "page-size",
"max-results": "page-size",
"page-no": "page",
"page-num": "page",
"per-page": "page-size",
"size": "page-size",
"take": "page-size",
"talent-pool-code": "pool-code",
"top": "page-size",
},
Blocked: []string{"count", "creator", "cursor", "dept-name", "employee-id", "job-level", "keyword", "labels", "page-index", "page-token", "pool-id", "position-name", "query", "staff-ids", "talent-pool-id", "work-no"},
Ambiguous: []string{"code", "id", "name", "pool", "status"},
},
{
CLIPath: "hrbrain talent-pool list",
Aliases: map[string]string{
"current-page": "page",
"keywords": "keyword",
"label-list": "labels",
"limit": "page-size",
"max-result": "page-size",
"max-results": "page-size",
"page-no": "page",
"page-num": "page",
"per-page": "page-size",
"q": "keyword",
"query": "keyword",
"search-word": "keyword",
"size": "page-size",
"take": "page-size",
"top": "page-size",
},
Blocked: []string{"count", "cursor", "employee-id", "name", "page-index", "page-token", "pool-code", "pool-id", "staff-ids", "subject", "talent-pool-id", "text", "title", "user-id", "work-no"},
Ambiguous: []string{"code", "creator-id", "id", "label-name", "pool", "type", "user"},
},
{
CLIPath: "live stream list",
Blocked: []string{"anchor-id", "conversation-id", "cover-url", "create", "creator-id", "cursor", "end", "end-live", "end-time", "from", "group-id", "keyword", "limit", "live-id", "offset", "order", "page", "page-size", "page-token", "playback", "query", "record", "room-id", "size", "sort", "start", "start-live", "start-time", "state", "status", "stream-id", "title", "to", "user-id"},
Ambiguous: []string{"date", "id", "owner", "time", "type", "user"},
},
{
CLIPath: "mail +contact-list",
Aliases: map[string]string{
@@ -7995,42 +7457,6 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"name", "subject", "text", "title"},
},
{
CLIPath: "pat +browser-policy",
Aliases: map[string]string{
"allow-browser": "enabled",
"browser-enabled": "enabled",
"open-browser": "enabled",
},
Blocked: []string{"api-scope", "app-id", "app-key", "app-secret", "block-browser", "browser-disabled", "deny-browser", "disable-browser", "domain", "domains", "grant-mode", "grant-type", "oauth-scope", "permission", "permissions", "product", "products", "recommend", "recommended", "scope", "scopes", "session-id"},
Ambiguous: []string{"agent", "agent-id", "browser", "mode", "policy", "state", "value"},
},
{
CLIPath: "pat browser-policy",
Aliases: map[string]string{
"allow-browser": "enabled",
"browser-enabled": "enabled",
"open-browser": "enabled",
},
Blocked: []string{"api-scope", "app-id", "app-key", "app-secret", "block-browser", "browser-disabled", "deny-browser", "disable-browser", "domain", "domains", "grant-mode", "grant-type", "oauth-scope", "permission", "permissions", "product", "products", "recommend", "recommended", "scope", "scopes", "session-id"},
Ambiguous: []string{"agent", "agent-id", "browser", "mode", "policy", "state", "value"},
},
{
CLIPath: "pat chmod",
Aliases: map[string]string{
"authorization-mode": "grant-type",
"authorization-session-id": "session-id",
"domain-code": "domain",
"domain-codes": "domains",
"grant-mode": "grant-type",
"pat-session-id": "session-id",
"product-code": "product",
"product-codes": "products",
"recommended": "recommend",
},
Blocked: []string{"all", "all-scopes", "allow-browser", "api-scope", "app-id", "app-key", "app-secret", "browser-enabled", "browser-policy", "confirm", "confirmed", "disable-browser", "force", "oauth-scope", "open-browser", "permission", "permission-scope", "permission-scopes", "permissions", "scope", "scopes"},
Ambiguous: []string{"action", "agent", "agent-id", "id", "mode", "resource", "service", "services", "session", "type"},
},
{
CLIPath: "report +inbox-list",
Aliases: map[string]string{
File diff suppressed because one or more lines are too long
+1 -8
View File
@@ -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.",
+18 -53
View File
@@ -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, "新的图片宽度,像素")
-333
View File
@@ -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)
}
}
+18 -64
View File
@@ -96,10 +96,7 @@ func newTodoCommand() *cobra.Command {
return err
}
executorsStr := mustGetFlag(cmd, "executors")
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
if err != nil {
return err
}
executorIds := parseExecutorIds(executorsStr)
toolArgs := map[string]any{
"PersonalTodoCreateVO": map[string]any{
"subject": flagOrFallback(cmd, "title", "subject", "content"),
@@ -114,11 +111,9 @@ func newTodoCommand() *cobra.Command {
toolArgs["PersonalTodoCreateVO"].(map[string]any)["dueTime"] = ms
}
if v, _ := cmd.Flags().GetString("priority"); v != "" {
n, err := parseTodoPriority(v)
if err != nil {
return err
if n, err := strconv.Atoi(v); err == nil {
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
}
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
}
if v, _ := cmd.Flags().GetString("recurrence"); v != "" {
toolArgs["PersonalTodoCreateVO"].(map[string]any)["recurrence"] = v
@@ -159,7 +154,7 @@ func newTodoCommand() *cobra.Command {
},
Parameters: []contract.ParamDecl{
{Name: "due", Property: "PersonalTodoCreateVO.dueTime"},
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds"},
{Name: "priority", Property: "PersonalTodoCreateVO.priority"},
{Name: "recurrence", Property: "PersonalTodoCreateVO.recurrence"},
{Name: "title", Property: "PersonalTodoCreateVO.subject"},
@@ -195,10 +190,7 @@ func newTodoCommand() *cobra.Command {
}
}
executorsStr := mustGetFlag(cmd, "executors")
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
if err != nil {
return err
}
executorIds := parseExecutorIds(executorsStr)
toolArgs := map[string]any{
"PersonalTodoCreateVO": map[string]any{
"subject": flagOrFallback(cmd, "title", "subject", "content"),
@@ -214,11 +206,9 @@ func newTodoCommand() *cobra.Command {
toolArgs["PersonalTodoCreateVO"].(map[string]any)["dueTime"] = ms
}
if v, _ := cmd.Flags().GetString("priority"); v != "" {
n, err := parseTodoPriority(v)
if err != nil {
return err
if n, err := strconv.Atoi(v); err == nil {
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
}
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
}
if v, _ := cmd.Flags().GetString("recurrence"); v != "" {
toolArgs["PersonalTodoCreateVO"].(map[string]any)["recurrence"] = v
@@ -259,7 +249,7 @@ func newTodoCommand() *cobra.Command {
},
Parameters: []contract.ParamDecl{
{Name: "due", Property: "PersonalTodoCreateVO.dueTime"},
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds"},
{Name: "parent-id", Property: "PersonalTodoCreateVO.parentId"},
{Name: "priority", Property: "PersonalTodoCreateVO.priority"},
{Name: "recurrence", Property: "PersonalTodoCreateVO.recurrence"},
@@ -386,11 +376,9 @@ func newTodoCommand() *cobra.Command {
inner["dueTime"] = ms
}
if v, _ := cmd.Flags().GetString("priority"); v != "" {
n, err := parseTodoPriority(v)
if err != nil {
return err
if n, err := strconv.Atoi(v); err == nil {
inner["priority"] = n
}
inner["priority"] = n
}
if v, _ := cmd.Flags().GetString("done"); v != "" {
inner["isDone"] = v == "true"
@@ -642,10 +630,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
return err
}
executorsStr := mustGetFlag(cmd, "executors")
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
if err != nil {
return err
}
executorIds := parseExecutorIds(executorsStr)
return callMCPTool("add_task_executors", map[string]any{
"todoExecutorsAddRequest": map[string]any{
"taskId": mustGetFlag(cmd, "task-id"),
@@ -687,7 +672,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
},
},
Parameters: []contract.ParamDecl{
{Name: "executors", Property: "todoExecutorsAddRequest.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
{Name: "executors", Property: "todoExecutorsAddRequest.executorIds"},
{Name: "task-id", Property: "todoExecutorsAddRequest.taskId"},
},
},
@@ -703,10 +688,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
return err
}
executorsStr := mustGetFlag(cmd, "executors")
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
if err != nil {
return err
}
executorIds := parseExecutorIds(executorsStr)
return callMCPTool("remove_task_executors", map[string]any{
"todoExecutorsRemoveRequest": map[string]any{
"taskId": mustGetFlag(cmd, "task-id"),
@@ -747,7 +729,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
},
},
Parameters: []contract.ParamDecl{
{Name: "executors", Property: "todoExecutorsRemoveRequest.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
{Name: "executors", Property: "todoExecutorsRemoveRequest.executorIds"},
{Name: "task-id", Property: "todoExecutorsRemoveRequest.taskId"},
},
},
@@ -1207,14 +1189,14 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
}
todoTaskCreateCmd.Flags().String("title", "", "待办标题 (必填)")
todoTaskCreateCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
todoTaskCreateCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
todoTaskCreateCmd.Flags().String("due", "", "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)")
todoTaskCreateCmd.Flags().String("priority", "", "优先级: 10低/20普通/30较高/40紧急")
todoTaskCreateCmd.Flags().String("recurrence", "", "循环待办 (需先设置 --due); 格式: DTSTART:20260320T100000Z\\nRRULE:FREQ=DAILY;INTERVAL=1")
todoTaskCreateSubCmd.Flags().String("parent-id", "", "父待办任务 ID (必填)")
todoTaskCreateSubCmd.Flags().String("title", "", "子待办标题 (必填)")
todoTaskCreateSubCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
todoTaskCreateSubCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
todoTaskCreateSubCmd.Flags().String("due", "", "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)")
todoTaskCreateSubCmd.Flags().String("priority", "", "优先级: 10低/20普通/30较高/40紧急")
todoTaskCreateSubCmd.Flags().String("recurrence", "", "循环待办 (需先设置 --due); 格式: DTSTART:20260320T100000Z\\nRRULE:FREQ=DAILY;INTERVAL=1")
@@ -1253,9 +1235,9 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
_ = todoTaskCmd.PersistentFlags().MarkHidden("remind-at")
todoTaskAddExecutorCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
todoTaskAddExecutorCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
todoTaskAddExecutorCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
todoTaskRemoveExecutorCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
todoTaskRemoveExecutorCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
todoTaskRemoveExecutorCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
todoTaskAddParticipantCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
todoTaskAddParticipantCmd.Flags().String("participants", "", "参与人 userId 列表 (必填)")
todoTaskRemoveParticipantCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
@@ -1911,34 +1893,6 @@ func parseExecutorIds(s string) []string {
return ids
}
func parseRequiredTodoIDs(flagName, value string) ([]string, error) {
ids := parseExecutorIds(value)
if len(ids) > 0 {
return ids, nil
}
return nil, apperrors.NewValidation(
fmt.Sprintf("--%s 至少需要一个非空 userId", flagName),
apperrors.WithReason("invalid_"+strings.ReplaceAll(flagName, "-", "_")),
apperrors.WithHint(fmt.Sprintf("使用 --%s <USER_ID>[,<USER_ID>...] 指定至少一个执行人", flagName)),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
)
}
func parseTodoPriority(value string) (int, error) {
priority, err := strconv.Atoi(strings.TrimSpace(value))
if err == nil {
return priority, nil
}
return 0, apperrors.NewValidation(
"--priority 必须是整数",
apperrors.WithReason("invalid_priority"),
apperrors.WithHint("常用优先级映射:10=低、20=普通、30=较高、40=紧急"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
)
}
type todoLocalFileMeta struct {
LocalPath string
FileName string
@@ -6,11 +6,8 @@ import (
"io"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func executeTodoEdge(t *testing.T, caller *scriptedToolCaller, args ...string) error {
@@ -47,11 +44,9 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) {
errorCases := [][]string{
{"task", "create", "--title", "x", "--executors", "u", "--remind-at", validDate},
{"task", "create", "--title", "x", "--executors", "u", "--due", "bad"},
{"task", "create", "--title", "x", "--executors", "u", "--priority", "bad"},
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "abc"},
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--remind-at", validDate},
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--due", "bad"},
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--priority", "bad"},
{"task", "list", "--role-types", "invalid"},
{"task", "list", "--plan-finish-date-start", "bad"},
{"task", "list", "--plan-finish-date-end", "bad"},
@@ -64,8 +59,9 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) {
validCases := [][]string{
{"task", "create", "--title", "x", "--executors", "u1,u2", "--due", validDate, "--priority", "40", "--recurrence", "daily"},
{"task", "create", "--subject", "x", "--executors", "u", "--priority", "20"},
{"task", "create", "--subject", "x", "--executors", "u", "--priority", "bad"},
{"task", "create-sub", "--content", "x", "--executors", "u", "--parent-id", "1", "--due", validDate, "--priority", "40", "--recurrence", "daily"},
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--priority", "bad"},
{"task", "list", "--size", "bad"},
{"task", "list", "--status", "true", "--priority", "40,bad", "--role-types", "creator,executor", "--plan-finish-date-start", validDate, "--plan-finish-date-end", validDate},
}
@@ -79,63 +75,6 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) {
}
}
func TestCrossPlatformCoverageTodoRejectsEmptyExecutorCSVAndNonNumericPriorityBeforeCall(t *testing.T) {
tests := []struct {
name string
reason string
args []string
}{
{name: "create empty executors", reason: "invalid_executors", args: []string{"task", "create", "--title", "x", "--executors", ",,,"}},
{name: "create-sub empty executors", reason: "invalid_executors", args: []string{"task", "create-sub", "--parent-id", "1", "--title", "x", "--executors", " , , "}},
{name: "add empty executors", reason: "invalid_executors", args: []string{"task", "add-executor", "--task-id", "1", "--executors", ",,,"}},
{name: "remove empty executors", reason: "invalid_executors", args: []string{"task", "remove-executor", "--task-id", "1", "--executors", ",,,"}},
{name: "create nonnumeric priority", reason: "invalid_priority", args: []string{"task", "create", "--title", "x", "--executors", "u", "--priority", "normal"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeTodoEdge(t, caller, test.args...)
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != test.reason {
t.Fatalf("error = %T %v, want reason %q", err, err, test.reason)
}
if typed.ExecutionStarted == nil || *typed.ExecutionStarted {
t.Fatalf("execution_started = %v, want false", typed.ExecutionStarted)
}
if caller.calls != 0 {
t.Fatalf("invalid input made %d MCP call(s)", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageTodoCreateAcceptsNumericPriority(t *testing.T) {
for _, priority := range []string{"20", "25"} {
t.Run(priority, func(t *testing.T) {
caller := &scriptedToolCaller{}
if err := executeTodoEdge(t, caller, "task", "create", "--title", "x", "--executors", "u", "--priority", priority); err != nil {
t.Fatalf("create priority %s: %v", priority, err)
}
if caller.calls != 1 || caller.tool != "create_personal_todo" {
t.Fatalf("calls = %d tool = %q", caller.calls, caller.tool)
}
request, ok := caller.args["PersonalTodoCreateVO"].(map[string]any)
if !ok || request["priority"] != mustTodoAtoi(t, priority) {
t.Fatalf("request = %#v, want priority %s", caller.args, priority)
}
})
}
}
func mustTodoAtoi(t *testing.T, value string) int {
t.Helper()
parsed, err := strconv.Atoi(value)
if err != nil {
t.Fatalf("parse %q: %v", value, err)
}
return parsed
}
func TestCrossPlatformCoverageTodoSimpleCommandValidationAndSuccessEdges(t *testing.T) {
errorCases := [][]string{
{"task", "create"},
@@ -204,7 +143,6 @@ func TestCrossPlatformCoverageTodoUpdateReminderAndDetailEdges(t *testing.T) {
errorCases := [][]string{
{"task", "update", "--task-id", "1", "--remind-at", validDate},
{"task", "update", "--task-id", "1", "--due", "bad"},
{"task", "update", "--task-id", "1", "--priority", "bad"},
{"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", "bad"},
{"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `[{"baseTime":"customTime","reminderTimeStamp":"bad"}]`},
{"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `not-json`},
@@ -217,6 +155,7 @@ func TestCrossPlatformCoverageTodoUpdateReminderAndDetailEdges(t *testing.T) {
}
validCases := [][]string{
{"task", "update", "--task-id", "1", "--title", "new", "--due", validDate, "--priority", "40", "--done", "true"},
{"task", "update", "--task-id", "1", "--priority", "bad"},
{"task", "add-reminder", "--task-id", "1", "--base-time", "dueTime", "--due-date-offset", "-10"},
{"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", validDate},
}
+32 -186
View File
@@ -19,172 +19,17 @@ trap cleanup EXIT HUP INT TERM
git -C "$ROOT" diff --no-ext-diff --find-renames --name-status "$merge_base" "$head" >"$tmp_root/status"
normalize_notes() {
awk '
/^[[:space:]]*$/ && !started { next }
{ started = 1; lines[++count] = $0 }
END {
while (count > 0 && lines[count] ~ /^[[:space:]]*$/) count--
for (line_no = 1; line_no <= count; line_no++) print lines[line_no]
}
' "$1"
}
extract_release_notes() {
git -C "$ROOT" show "$1:CHANGELOG.md" |
awk -v heading="## [$2] - " '
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
'
}
release_heading() {
git -C "$ROOT" show "$1:CHANGELOG.md" |
awk -v heading="## [$2] - " 'index($0, heading) == 1 { print }'
}
changelog_without_release_notes() {
git -C "$ROOT" show "$1:CHANGELOG.md" |
awk -v heading="## [$2] - " '
index($0, heading) == 1 { print; skipped = 1; next }
skipped && /^## / { skipped = 0 }
!skipped { print }
'
}
# A pre-tag beta amendment must preserve the original seal verbatim except for
# appending newly archived fragment bodies to their existing categories. These
# helpers canonicalize the renderer's category format before comparing it.
canonicalize_fragment_notes() {
awk '
BEGIN { order[1] = "Added"; order[2] = "Changed"; order[3] = "Deprecated"; order[4] = "Removed"; order[5] = "Fixed"; order[6] = "Security" }
function trim(value) {
sub(/^[\n]+/, "", value)
sub(/[\n]+$/, "", value)
return value
}
function flush() {
if (category == "") return
value = trim(body)
if (value == "" || seen[category]++) invalid = 1
blocks[category] = value
category = ""
body = ""
}
/^### / {
flush()
candidate = substr($0, 5)
if (candidate !~ /^(Added|Changed|Deprecated|Removed|Fixed|Security)$/) {
invalid = 1
next
}
category = candidate
next
}
{
if (category == "") {
if ($0 !~ /^[[:space:]]*$/) invalid = 1
next
}
body = body $0 "\n"
}
END {
flush()
if (invalid) exit 1
for (i = 1; i <= 6; i++) {
category = order[i]
if (category in blocks) printf "### %s\n\n%s\n\n", category, blocks[category]
}
}
' "$1"
}
merge_canonical_fragment_notes() {
awk '
BEGIN { order[1] = "Added"; order[2] = "Changed"; order[3] = "Deprecated"; order[4] = "Removed"; order[5] = "Fixed"; order[6] = "Security" }
function trim(value) {
sub(/^[\n]+/, "", value)
sub(/[\n]+$/, "", value)
return value
}
function flush() {
if (category == "") return
value = trim(body)
if (value == "") invalid = 1
if (part == 1) before[category] = value
else if (part == 2) addition[category] = value
else invalid = 1
category = ""
body = ""
}
FNR == 1 {
if (started) flush()
started = 1
part++
}
/^### / {
flush()
category = substr($0, 5)
if (category !~ /^(Added|Changed|Deprecated|Removed|Fixed|Security)$/) invalid = 1
next
}
{ body = body $0 "\n" }
END {
flush()
if (invalid || part != 2) exit 1
for (i = 1; i <= 6; i++) {
category = order[i]
if (!(category in before) && !(category in addition)) continue
printf "### %s\n\n", category
if (category in before) printf "%s\n\n", before[category]
if (category in addition) printf "%s\n\n", addition[category]
}
}
' "$1" "$2"
}
archive_changed=false
if awk -F '\t' '{ for (field = 2; field <= NF; field++) if ($field ~ /^\.changes\/released\//) found = 1 } END { exit !found }' "$tmp_root/status"; then
archive_changed=true
fi
if [ "$archive_changed" = true ]; then
new_release_version="$(git -C "$ROOT" diff --no-ext-diff --unified=0 "$merge_base" "$head" -- CHANGELOG.md | sed -n 's/^+## \[\([0-9][0-9.]*\(-beta\.[1-9][0-9]*\)\{0,1\}\)\] - .*/\1/p')"
new_release_version_count="$(printf '%s\n' "$new_release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')"
archival_mode=seal
if [ "$new_release_version_count" -eq 1 ]; then
release_version="$new_release_version"
else
# An amendment keeps the existing heading. Derive the one allowed target
# from the literal archive directory, then verify every move below.
release_version="$(awk -F '\t' '$1 == "R100" && NF == 3 && $3 ~ /^\.changes\/released\// { path = $3; sub(/^\.changes\/released\//, "", path); sub(/\/.*/, "", path); print path }' "$tmp_root/status" | sort -u)"
[ "$(printf '%s\n' "$release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
printf '%s\n' 'error: release-fragment archival requires exactly one newly added versioned CHANGELOG section or one untagged beta amendment target' >&2
exit 1
}
printf '%s\n' "$release_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]*$' || {
printf '%s\n' 'error: only an existing beta CHANGELOG section may receive a pre-tag fragment amendment' >&2
exit 1
}
base_heading="$(release_heading "$merge_base" "$release_version")"
head_heading="$(release_heading "$head" "$release_version")"
[ "$(printf '%s\n' "$base_heading" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] && [ "$base_heading" = "$head_heading" ] || {
printf '%s\n' 'error: beta fragment amendment requires one unchanged pre-existing CHANGELOG heading' >&2
exit 1
}
changelog_without_release_notes "$merge_base" "$release_version" >"$tmp_root/base-changelog-without-amendment"
changelog_without_release_notes "$head" "$release_version" >"$tmp_root/head-changelog-without-amendment"
if ! cmp -s "$tmp_root/base-changelog-without-amendment" "$tmp_root/head-changelog-without-amendment"; then
printf '%s\n' 'error: beta fragment amendment may change only its existing beta CHANGELOG section' >&2
exit 1
fi
if git -C "$ROOT" rev-parse --verify --quiet "refs/tags/v$release_version^{commit}" >/dev/null; then
printf 'error: beta fragment amendment is forbidden after tag v%s exists\n' "$release_version" >&2
exit 1
fi
archival_mode=beta-amendment
fi
release_version="$(git -C "$ROOT" diff --no-ext-diff --unified=0 "$merge_base" "$head" -- CHANGELOG.md | sed -n 's/^+## \[\([0-9][0-9.]*\(-beta\.[1-9][0-9]*\)\{0,1\}\)\] - .*/\1/p')"
[ "$(printf '%s\n' "$release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
printf '%s\n' 'error: release-fragment archival requires exactly one newly added versioned CHANGELOG section' >&2
exit 1
}
# The archive directory is matched as a literal prefix, never as a regex:
# interpolating the version into one would make `.` match any character, so
# `1.0.1-beta.1` would also admit `.changes/released/1x0x1-betaX1/` and break
@@ -202,7 +47,7 @@ if [ "$archive_changed" = true ]; then
{ invalid = 1 }
END { exit !(changelog && moved > 0 && !invalid) }
' "$tmp_root/status"; then
printf '%s\n' 'error: release fragments must be unchanged R100 moves from .changes/<name>.md to .changes/released/<new-version>/<name>.md in the matching release-seal or untagged beta-amendment PR' >&2
printf '%s\n' 'error: release fragments must be unchanged R100 moves from .changes/<name>.md to .changes/released/<new-version>/<name>.md in the matching release-seal PR' >&2
exit 1
fi
source_changes="$tmp_root/source-changes"
@@ -218,25 +63,17 @@ if [ "$archive_changed" = true ]; then
esac
done
"$ROOT/scripts/release/render-release-fragments.sh" "$source_changes" >"$tmp_root/expected-notes"
if [ "$archival_mode" = beta-amendment ]; then
extract_release_notes "$merge_base" "$release_version" >"$tmp_root/base-notes"
extract_release_notes "$head" "$release_version" >"$tmp_root/head-notes"
if ! canonicalize_fragment_notes "$tmp_root/base-notes" >"$tmp_root/base-notes.canonical" ||
! canonicalize_fragment_notes "$tmp_root/expected-notes" >"$tmp_root/amendment-notes.canonical" ||
! merge_canonical_fragment_notes "$tmp_root/base-notes.canonical" "$tmp_root/amendment-notes.canonical" >"$tmp_root/expected-notes.merged" ||
! canonicalize_fragment_notes "$tmp_root/head-notes" >"$tmp_root/actual-notes.canonical"; then
printf '%s\n' 'error: beta fragment amendment requires canonical rendered fragment categories' >&2
exit 1
fi
normalize_notes "$tmp_root/expected-notes.merged" >"$tmp_root/expected-notes.normalized"
normalize_notes "$tmp_root/actual-notes.canonical" >"$tmp_root/actual-notes.normalized"
else
extract_release_notes "$head" "$release_version" >"$tmp_root/actual-section"
case "$release_version" in
*-beta.*)
cp "$tmp_root/actual-section" "$tmp_root/actual-notes"
;;
*)
git -C "$ROOT" show "$head:CHANGELOG.md" |
awk -v heading="## [$release_version] - " '
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
' >"$tmp_root/actual-section"
case "$release_version" in
*-beta.*)
cp "$tmp_root/actual-section" "$tmp_root/actual-notes"
;;
*)
stable_beta="$(sed -n 's/^### Changes since `\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*\)`$/\1/p' "$tmp_root/actual-section")"
[ "$(printf '%s\n' "$stable_beta" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
printf '%s\n' 'error: stable release-seal with archived fragments requires exactly one ### Changes since `vX.Y.Z-beta.N` boundary' >&2
@@ -251,13 +88,22 @@ if [ "$archive_changed" = true ]; then
found { print }
/^### Changes since `v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*`$/ { found = 1 }
' "$tmp_root/actual-section" >"$tmp_root/actual-notes"
;;
esac
normalize_notes "$tmp_root/expected-notes" >"$tmp_root/expected-notes.normalized"
normalize_notes "$tmp_root/actual-notes" >"$tmp_root/actual-notes.normalized"
fi
;;
esac
normalize_notes() {
awk '
/^[[:space:]]*$/ && !started { next }
{ started = 1; lines[++count] = $0 }
END {
while (count > 0 && lines[count] ~ /^[[:space:]]*$/) count--
for (line_no = 1; line_no <= count; line_no++) print lines[line_no]
}
' "$1"
}
normalize_notes "$tmp_root/expected-notes" >"$tmp_root/expected-notes.normalized"
normalize_notes "$tmp_root/actual-notes" >"$tmp_root/actual-notes.normalized"
if ! cmp -s "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized"; then
printf '%s\n' 'error: release CHANGELOG section does not exactly match the rendered active release fragments' >&2
printf '%s\n' 'error: release-seal CHANGELOG section does not exactly match the rendered active release fragments' >&2
diff -u "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized" >&2 || true
exit 1
fi
+3 -3
View File
@@ -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` 操作不可恢复,删除后图片将从工作表中移除
-73
View File
@@ -351,79 +351,6 @@ This release promotes the sealed `+"`v1.0.1-beta.1`"+` contents to stable.
}
}
func TestReleaseFragmentPolicyAcceptsOnlyUntaggedCanonicalBetaAmendments(t *testing.T) {
newAmendmentRepo := func(t *testing.T) (*changelogGateRepo, string) {
t.Helper()
repo := newChangelogGateRepo(t)
sealBase := repo.sealFragmentInto(t, "1.0.1-beta.1")
if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil {
t.Fatalf("release fragment policy rejected initial beta seal: %v\noutput:\n%s", err, output)
}
changelogGateWrite(t, repo.root, ".changes/1235-sheet.md", "---\ncategory: Added\n---\n\n- Sheet accepts local float image files.\n", 0o644)
repo.commit(t, "merge post-seal beta fragment")
return repo, strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
}
stageCanonicalAmendment := func(t *testing.T, repo *changelogGateRepo) {
t.Helper()
changelogGateWrite(t, repo.root, "CHANGELOG.md", `# Changelog
## [Unreleased]
## [1.0.1-beta.1] - 2026-07-17
### Added
- Chat reply mentions.
- Sheet accepts local float image files.
## [1.0.0] - 2026-07-01
### Added
- Initial release.
`, 0o644)
archiveDir := filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1")
if err := os.Rename(filepath.Join(repo.root, ".changes", "1235-sheet.md"), filepath.Join(archiveDir, "1235-sheet.md")); err != nil {
t.Fatalf("Rename beta amendment fragment: %v", err)
}
repo.commit(t, "amend untagged beta release notes")
}
t.Run("accepts exact pre-tag merge", func(t *testing.T) {
repo, amendmentBase := newAmendmentRepo(t)
stageCanonicalAmendment(t, repo)
if output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD"); err != nil {
t.Fatalf("release fragment policy rejected canonical beta amendment: %v\noutput:\n%s", err, output)
}
})
t.Run("rejects tagged beta", func(t *testing.T) {
repo, amendmentBase := newAmendmentRepo(t)
stageCanonicalAmendment(t, repo)
changelogGateGit(t, repo.root, "tag", "v1.0.1-beta.1")
output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD")
if err == nil || !strings.Contains(output, "forbidden after tag v1.0.1-beta.1 exists") {
t.Fatalf("tagged beta amendment passed: err=%v\noutput:\n%s", err, output)
}
})
t.Run("rejects rewritten sealed prose", func(t *testing.T) {
repo, amendmentBase := newAmendmentRepo(t)
stageCanonicalAmendment(t, repo)
changelogGateWrite(t, repo.root, "CHANGELOG.md", strings.Replace(changelogGateSealedRelease, "Chat reply mentions.", "Rewritten sealed note.", 1), 0o644)
repo.commit(t, "rewrite sealed beta prose")
output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD")
if err == nil || !strings.Contains(output, "does not exactly match") {
t.Fatalf("rewritten beta amendment passed: err=%v\noutput:\n%s", err, output)
}
})
}
func TestReleaseFragmentPolicyRejectsInvalidActiveFragmentAndWrongArchiveVersion(t *testing.T) {
t.Run("invalid active fragment", func(t *testing.T) {
repo := newChangelogGateRepo(t)