-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat(docs): add cover-get/cover-update/cover-delete for docx cover image #1370
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,227 @@ | ||
| // Copyright (c) 2026 Lark Technologies Pte. Ltd. | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| package doc | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "io" | ||
| "math" | ||
| "strconv" | ||
| "strings" | ||
|
|
||
| "github.com/larksuite/cli/errs" | ||
| "github.com/larksuite/cli/shortcuts/common" | ||
| ) | ||
|
|
||
| // docxDocumentAPIPath is the docx v1 document endpoint used for cover GET/PATCH. | ||
| const docxDocumentAPIPath = "/open-apis/docx/v1/documents/%s" | ||
|
|
||
| // resolveCoverDocumentID returns the docx document_id for cover operations. | ||
| // The cover OpenAPI (GET/PATCH /open-apis/docx/v1/documents/:document_id) only | ||
| // accepts a docx document_id. wiki/doc refs are rejected with a structured, | ||
| // actionable error — this iteration does not resolve wiki → docx. | ||
| func resolveCoverDocumentID(runtime *common.RuntimeContext) (string, error) { | ||
| ref, err := parseDocumentRef(runtime.Str("doc")) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| if ref.Kind != "docx" { | ||
| return "", errs.NewValidationError(errs.SubtypeInvalidArgument, | ||
| "--doc kind %q is not supported for cover operations; pass a docx document URL or token (the cover API needs a docx document_id)", ref.Kind).WithParam("--doc") | ||
| } | ||
| return ref.Token, nil | ||
| } | ||
|
|
||
| // parseOptionalOffset reads an optional float flag. Returns (value, present, error). | ||
| // Not provided (empty) → present=false so the caller omits the field entirely | ||
| // (no default is injected). Provided → only finite numbers pass; NaN/Inf/non-numeric | ||
| // are rejected client-side. The accepted numeric range is left to the server. | ||
| func parseOptionalOffset(runtime *common.RuntimeContext, name string) (float64, bool, error) { | ||
| raw := strings.TrimSpace(runtime.Str(name)) | ||
| if raw == "" { | ||
| return 0, false, nil | ||
| } | ||
| v, err := strconv.ParseFloat(raw, 64) | ||
| if err != nil || math.IsNaN(v) || math.IsInf(v, 0) { | ||
| return 0, false, errs.NewValidationError(errs.SubtypeInvalidArgument, | ||
| "--%s must be a finite number, got %q", name, raw).WithParam("--" + name) | ||
| } | ||
| return v, true, nil | ||
| } | ||
|
|
||
| // extractCover pulls data.document.cover out of the docx document response envelope. | ||
| func extractCover(data map[string]interface{}) interface{} { | ||
| doc, ok := data["document"].(map[string]interface{}) | ||
| if !ok { | ||
| return nil | ||
| } | ||
| return doc["cover"] | ||
| } | ||
|
|
||
| // ---------------- cover-get ---------------- | ||
|
|
||
| func validateCoverDoc(_ context.Context, runtime *common.RuntimeContext) error { | ||
| _, err := resolveCoverDocumentID(runtime) | ||
| return err | ||
| } | ||
|
|
||
| func dryRunCoverGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { | ||
| id, _ := resolveCoverDocumentID(runtime) | ||
| return common.NewDryRunAPI(). | ||
| GET(fmt.Sprintf(docxDocumentAPIPath, id)). | ||
| Desc("OpenAPI: get document (cover in data.document.cover)"). | ||
| Set("document_id", id) | ||
| } | ||
|
|
||
| func executeCoverGet(_ context.Context, runtime *common.RuntimeContext) error { | ||
| id, _ := resolveCoverDocumentID(runtime) | ||
| data, err := doDocAPI(runtime, "GET", fmt.Sprintf(docxDocumentAPIPath, id), nil) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| cover := extractCover(data) | ||
| runtime.OutFormatRaw(map[string]interface{}{"cover": cover}, nil, func(w io.Writer) { | ||
| if cover == nil { | ||
| fmt.Fprintln(w, "(no cover)") | ||
| return | ||
| } | ||
| if m, ok := cover.(map[string]interface{}); ok { | ||
| fmt.Fprintf(w, "token=%v offset_ratio_x=%v offset_ratio_y=%v\n", m["token"], m["offset_ratio_x"], m["offset_ratio_y"]) | ||
| } | ||
| }) | ||
| return nil | ||
| } | ||
|
|
||
| var DocsCoverGet = common.Shortcut{ | ||
| Service: "docs", | ||
| Command: "+cover-get", | ||
| Description: "Get a docx document cover image (token + offset ratios)", | ||
| Risk: "read", | ||
| Scopes: []string{"docx:document:readonly"}, | ||
| AuthTypes: []string{"user", "bot"}, | ||
| HasFormat: true, | ||
| Flags: []common.Flag{ | ||
| {Name: "doc", Desc: "docx document URL or token", Required: true}, | ||
| }, | ||
| Validate: validateCoverDoc, | ||
| DryRun: dryRunCoverGet, | ||
| Execute: executeCoverGet, | ||
| } | ||
|
|
||
| // ---------------- cover-update ---------------- | ||
|
|
||
| func validateCoverUpdate(_ context.Context, runtime *common.RuntimeContext) error { | ||
| if _, err := resolveCoverDocumentID(runtime); err != nil { | ||
| return err | ||
| } | ||
| if strings.TrimSpace(runtime.Str("token")) == "" { | ||
| return errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token") | ||
| } | ||
| if _, _, err := parseOptionalOffset(runtime, "offset-ratio-x"); err != nil { | ||
| return err | ||
| } | ||
| if _, _, err := parseOptionalOffset(runtime, "offset-ratio-y"); err != nil { | ||
| return err | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // buildCoverUpdateBody assembles {update_cover:{cover:{token, offset_ratio_x?, offset_ratio_y?}}}. | ||
| // Offsets are written only when explicitly provided; no default is injected so the | ||
| // server applies its existing default crop behavior when omitted. | ||
| func buildCoverUpdateBody(runtime *common.RuntimeContext) map[string]interface{} { | ||
| cover := map[string]interface{}{"token": strings.TrimSpace(runtime.Str("token"))} | ||
| if v, ok, _ := parseOptionalOffset(runtime, "offset-ratio-x"); ok { | ||
| cover["offset_ratio_x"] = v | ||
| } | ||
| if v, ok, _ := parseOptionalOffset(runtime, "offset-ratio-y"); ok { | ||
| cover["offset_ratio_y"] = v | ||
| } | ||
| return map[string]interface{}{"update_cover": map[string]interface{}{"cover": cover}} | ||
| } | ||
|
|
||
| func dryRunCoverUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { | ||
| id, _ := resolveCoverDocumentID(runtime) | ||
| return common.NewDryRunAPI(). | ||
| PATCH(fmt.Sprintf(docxDocumentAPIPath, id)). | ||
| Desc("OpenAPI: update document cover"). | ||
| Body(buildCoverUpdateBody(runtime)). | ||
| Set("document_id", id) | ||
| } | ||
|
|
||
| func executeCoverUpdate(_ context.Context, runtime *common.RuntimeContext) error { | ||
| id, _ := resolveCoverDocumentID(runtime) | ||
| data, err := doDocAPI(runtime, "PATCH", fmt.Sprintf(docxDocumentAPIPath, id), buildCoverUpdateBody(runtime)) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| runtime.OutFormatRaw(map[string]interface{}{"cover": extractCover(data)}, nil, func(w io.Writer) { | ||
| fmt.Fprintln(w, "cover updated") | ||
| }) | ||
| return nil | ||
| } | ||
|
|
||
| var DocsCoverUpdate = common.Shortcut{ | ||
| Service: "docs", | ||
| Command: "+cover-update", | ||
| Description: "Update a docx document cover image (token must have docx_image relation to the doc)", | ||
| Risk: "write", | ||
| Scopes: []string{"docx:document"}, | ||
| AuthTypes: []string{"user", "bot"}, | ||
| HasFormat: true, | ||
| Flags: []common.Flag{ | ||
| {Name: "doc", Desc: "docx document URL or token", Required: true}, | ||
| {Name: "token", Desc: "cover image file_token; must be uploaded with docx_image relation to this doc (use `docs +media-upload --parent-type docx_image --parent-node <doc-id> --doc-id <doc-id>`); a `docs +media-insert` body image token will be rejected with a relation mismatch", Required: true}, | ||
| {Name: "offset-ratio-x", Type: "float64", Desc: "optional horizontal cover offset ratio (aligns with Docx OpenAPI document.cover.offset_ratio_x); omit to keep server default; only finite numbers accepted, range validated server-side"}, | ||
| {Name: "offset-ratio-y", Type: "float64", Desc: "optional vertical cover offset ratio (aligns with Docx OpenAPI document.cover.offset_ratio_y); omit to keep server default; only finite numbers accepted, range validated server-side"}, | ||
| }, | ||
| Validate: validateCoverUpdate, | ||
| DryRun: dryRunCoverUpdate, | ||
| Execute: executeCoverUpdate, | ||
| } | ||
|
|
||
| // ---------------- cover-delete ---------------- | ||
|
|
||
| // buildCoverDeleteBody assembles {update_cover:{cover:null}} per the OpenAPI delete convention. | ||
| func buildCoverDeleteBody() map[string]interface{} { | ||
| return map[string]interface{}{"update_cover": map[string]interface{}{"cover": nil}} | ||
| } | ||
|
|
||
| func dryRunCoverDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { | ||
| id, _ := resolveCoverDocumentID(runtime) | ||
| return common.NewDryRunAPI(). | ||
| PATCH(fmt.Sprintf(docxDocumentAPIPath, id)). | ||
| Desc("OpenAPI: delete document cover (cover:null)"). | ||
| Body(buildCoverDeleteBody()). | ||
| Set("document_id", id) | ||
| } | ||
|
|
||
| func executeCoverDelete(_ context.Context, runtime *common.RuntimeContext) error { | ||
| id, _ := resolveCoverDocumentID(runtime) | ||
| data, err := doDocAPI(runtime, "PATCH", fmt.Sprintf(docxDocumentAPIPath, id), buildCoverDeleteBody()) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| runtime.OutFormatRaw(map[string]interface{}{"cover": extractCover(data)}, nil, func(w io.Writer) { | ||
| fmt.Fprintln(w, "cover deleted") | ||
| }) | ||
| return nil | ||
| } | ||
|
|
||
| var DocsCoverDelete = common.Shortcut{ | ||
| Service: "docs", | ||
| Command: "+cover-delete", | ||
| Description: "Delete a docx document cover image (sends cover:null)", | ||
| Risk: "write", | ||
| Scopes: []string{"docx:document"}, | ||
| AuthTypes: []string{"user", "bot"}, | ||
| HasFormat: true, | ||
| Flags: []common.Flag{ | ||
| {Name: "doc", Desc: "docx document URL or token", Required: true}, | ||
| }, | ||
| Validate: validateCoverDoc, | ||
| DryRun: dryRunCoverDelete, | ||
| Execute: executeCoverDelete, | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| // Copyright (c) 2026 Lark Technologies Pte. Ltd. | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| package doc | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| "github.com/larksuite/cli/shortcuts/common" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| func newCoverTestRuntime() *common.RuntimeContext { | ||
| cmd := &cobra.Command{Use: "+cover"} | ||
| cmd.Flags().String("doc", "", "") | ||
| cmd.Flags().String("token", "", "") | ||
| cmd.Flags().String("offset-ratio-x", "", "") | ||
| cmd.Flags().String("offset-ratio-y", "", "") | ||
| return common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) | ||
| } | ||
|
|
||
| func TestResolveCoverDocumentID(t *testing.T) { | ||
| cases := []struct { | ||
| name string | ||
| doc string | ||
| wantID string | ||
| wantErr bool | ||
| }{ | ||
| {"raw token", "doxcnAbc123", "doxcnAbc123", false}, | ||
| {"docx url", "https://x.larkoffice.com/docx/doxcnAbc123", "doxcnAbc123", false}, | ||
| {"wiki url rejected", "https://x.larkoffice.com/wiki/wikAbc123", "", true}, | ||
| {"empty rejected", "", "", true}, | ||
| } | ||
| for _, tt := range cases { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| rt := newCoverTestRuntime() | ||
| _ = rt.Cmd.Flags().Set("doc", tt.doc) | ||
| id, err := resolveCoverDocumentID(rt) | ||
| if tt.wantErr { | ||
| if err == nil { | ||
| t.Fatalf("expected error for %q, got id=%q", tt.doc, id) | ||
| } | ||
| return | ||
| } | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if id != tt.wantID { | ||
| t.Fatalf("id = %q, want %q", id, tt.wantID) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestParseOptionalOffset(t *testing.T) { | ||
| cases := []struct { | ||
| name string | ||
| val string | ||
| wantPresent bool | ||
| wantVal float64 | ||
| wantErr bool | ||
| }{ | ||
| {"not provided", "", false, 0, false}, | ||
| {"valid float", "0.25", true, 0.25, false}, | ||
| {"valid negative", "-0.5", true, -0.5, false}, | ||
| {"non-numeric", "abc", false, 0, true}, | ||
| {"NaN", "NaN", false, 0, true}, | ||
| {"Inf", "Inf", false, 0, true}, | ||
| } | ||
| for _, tt := range cases { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| rt := newCoverTestRuntime() | ||
| _ = rt.Cmd.Flags().Set("offset-ratio-x", tt.val) | ||
| v, present, err := parseOptionalOffset(rt, "offset-ratio-x") | ||
| if tt.wantErr { | ||
| if err == nil { | ||
| t.Fatalf("expected error for %q", tt.val) | ||
| } | ||
| return | ||
| } | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if present != tt.wantPresent { | ||
| t.Fatalf("present = %v, want %v", present, tt.wantPresent) | ||
| } | ||
| if present && v != tt.wantVal { | ||
| t.Fatalf("val = %v, want %v", v, tt.wantVal) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestBuildCoverUpdateBodyOmitsOffsetWhenUnset(t *testing.T) { | ||
| rt := newCoverTestRuntime() | ||
| _ = rt.Cmd.Flags().Set("token", "filetokenABC") | ||
|
|
||
| body := buildCoverUpdateBody(rt) | ||
| cover := body["update_cover"].(map[string]interface{})["cover"].(map[string]interface{}) | ||
| if cover["token"] != "filetokenABC" { | ||
| t.Fatalf("token = %#v, want filetokenABC", cover["token"]) | ||
| } | ||
| if _, ok := cover["offset_ratio_x"]; ok { | ||
| t.Fatalf("offset_ratio_x must be omitted when unset: %#v", cover) | ||
| } | ||
| if _, ok := cover["offset_ratio_y"]; ok { | ||
| t.Fatalf("offset_ratio_y must be omitted when unset: %#v", cover) | ||
| } | ||
| } | ||
|
|
||
| func TestBuildCoverUpdateBodyIncludesOffsetWhenSet(t *testing.T) { | ||
| rt := newCoverTestRuntime() | ||
| _ = rt.Cmd.Flags().Set("token", "filetokenABC") | ||
| _ = rt.Cmd.Flags().Set("offset-ratio-x", "0.1") | ||
| _ = rt.Cmd.Flags().Set("offset-ratio-y", "0.2") | ||
|
|
||
| body := buildCoverUpdateBody(rt) | ||
| cover := body["update_cover"].(map[string]interface{})["cover"].(map[string]interface{}) | ||
| if cover["offset_ratio_x"] != 0.1 { | ||
| t.Fatalf("offset_ratio_x = %#v, want 0.1", cover["offset_ratio_x"]) | ||
| } | ||
| if cover["offset_ratio_y"] != 0.2 { | ||
| t.Fatalf("offset_ratio_y = %#v, want 0.2", cover["offset_ratio_y"]) | ||
| } | ||
| } | ||
|
|
||
| func TestBuildCoverDeleteBodyIsNull(t *testing.T) { | ||
| body := buildCoverDeleteBody() | ||
| cover, ok := body["update_cover"].(map[string]interface{}) | ||
| if !ok { | ||
| t.Fatalf("update_cover missing: %#v", body) | ||
| } | ||
| v, present := cover["cover"] | ||
| if !present { | ||
| t.Fatalf("cover key must be present (explicit null): %#v", cover) | ||
| } | ||
| if v != nil { | ||
| t.Fatalf("cover must be nil for delete, got %#v", v) | ||
| } | ||
| } | ||
|
|
||
| func TestValidateCoverUpdateRequiresToken(t *testing.T) { | ||
| rt := newCoverTestRuntime() | ||
| _ = rt.Cmd.Flags().Set("doc", "doxcnAbc123") | ||
| // no --token | ||
| if err := validateCoverUpdate(context.Background(), rt); err == nil { | ||
| t.Fatal("expected error when --token missing") | ||
| } | ||
|
|
||
| _ = rt.Cmd.Flags().Set("token", "filetokenABC") | ||
| if err := validateCoverUpdate(context.Background(), rt); err != nil { | ||
| t.Fatalf("unexpected error with token set: %v", err) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Strengthen error-path assertions to validate typed error contract.
These branches only assert “error exists,” so subtype/category/param regressions can slip through. Assert structured metadata (
errs.ProblemOf) andParamviaerrors.As(..., *errs.ValidationError), plus wrapped cause where applicable.Suggested assertion pattern
As per coding guidelines, “Error-path tests must assert typed metadata via
errs.ProblemOf(category/subtype/param) and cause preservation, not message substrings alone.” Based on learnings,errs.ProblemOf(err)does not exposeParam; assertParamwitherrors.As(err, *errs.ValidationError).Also applies to: 76-80, 147-149
🤖 Prompt for AI Agents
Sources: Coding guidelines, Learnings