Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0aea5ea
feat(doc): add --from-clipboard flag to docs +media-insert
herbertliu Apr 16, 2026
aec1b48
fix(doc): fix clipboard image read on macOS for screenshots and brows…
herbertliu Apr 16, 2026
fb1fca0
fix(doc): scan HTML/RTF/text clipboard formats for base64 image data …
herbertliu Apr 16, 2026
cf9e305
test(doc): add unit tests for clipboard helpers to meet 60% coverage …
herbertliu Apr 16, 2026
1236bd0
fix(doc): address CodeRabbit review comments on clipboard feature
herbertliu Apr 16, 2026
6df0695
fix(doc): replace os.* temp-file clipboard path with in-memory streaming
herbertliu Apr 16, 2026
608f3cd
fix(doc): address CodeRabbit review comments on Linux clipboard path
herbertliu Apr 16, 2026
adac5bf
fix(doc): strip whitespace from base64 payload before decoding clipbo…
herbertliu Apr 16, 2026
b092d32
fix(doc): drop TIFF fallback and internal/vfs import on macOS clipboard
herbertliu Apr 17, 2026
db3f16b
test(doc): cover readClipboardLinux xsel PNG validation and dispatche…
herbertliu Apr 17, 2026
71084fb
Merge remote-tracking branch 'origin/main' into feat/media-insert-cli…
herbertliu Apr 17, 2026
58f8bae
test: cover in-memory Content upload paths for clipboard feature
herbertliu Apr 17, 2026
210fb5c
test: cover clipboard Validate/DryRun branches and testing helper
herbertliu Apr 17, 2026
9dedb7a
test: cover Execute clipboard branch via injectable readClipboardImage
herbertliu Apr 18, 2026
8f46cec
ci: re-trigger pull_request workflow for PR #508
herbertliu Apr 19, 2026
5da1c09
Merge remote-tracking branch 'origin/main' into feat/media-insert-cli…
herbertliu Apr 20, 2026
350befa
test(doc): guard info.Size() behind err check to prevent nil-deref
herbertliu Apr 20, 2026
abe663b
Merge remote-tracking branch 'origin/main' into feat/media-insert-cli…
herbertliu Apr 21, 2026
4764c68
fix(doc): address fangshuyu-768 review on clipboard PR
herbertliu Apr 21, 2026
a0b5548
fix(doc): capture line-wrapped base64 in clipboard data URI regex (#586)
fangshuyu-768 Apr 21, 2026
c84eb9c
Merge remote-tracking branch 'origin/main' into feat/media-insert-cli…
herbertliu Apr 22, 2026
a44fc8f
docs(skill): add clipboard-empty fallback guidance for +media-insert
herbertliu Apr 22, 2026
7f0ff59
docs(skill): user-stated source trumps clipboard/file heuristic
herbertliu Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions shortcuts/common/drive_media_upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
// Reader, when non-nil, is used as the upload source instead of opening
// FilePath. Callers must set FileName and FileSize explicitly. The reader
// is NOT closed by UploadDriveMediaAll; the caller owns its lifetime.
// Used by the clipboard path in docs +media-insert.
Reader io.Reader
}

Expand All @@ -50,6 +51,8 @@
ParentType string
ParentNode string
Extra string
// Reader mirrors DriveMediaUploadAllConfig.Reader for chunked uploads.
Reader io.Reader
}

func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
Expand Down Expand Up @@ -118,7 +121,7 @@
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))

if err = uploadDriveMediaMultipartParts(runtime, cfg.FilePath, cfg.FileSize, session); err != nil {
if err = uploadDriveMediaMultipartParts(runtime, cfg, session); err != nil {
return "", err
}

Expand Down Expand Up @@ -176,20 +179,26 @@
return fileToken, nil
}

func uploadDriveMediaMultipartParts(runtime *RuntimeContext, filePath string, fileSize int64, session DriveMediaMultipartUploadSession) error {
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return WrapInputStatError(err)
func uploadDriveMediaMultipartParts(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig, session DriveMediaMultipartUploadSession) error {
var r io.Reader
if cfg.Reader != nil {
r = cfg.Reader
} else {
f, err := runtime.FileIO().Open(cfg.FilePath)
if err != nil {
return WrapInputStatError(err)
}

Check warning on line 190 in shortcuts/common/drive_media_upload.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/drive_media_upload.go#L189-L190

Added lines #L189 - L190 were not covered by tests
defer f.Close()
r = f
}
defer f.Close()

maxInt := int64(^uint(0) >> 1)
bufferSize := session.BlockSize
if bufferSize <= 0 || bufferSize > maxInt {
return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
}
buffer := make([]byte, int(bufferSize))
remaining := fileSize
remaining := cfg.FileSize
// Follow the server-declared block plan exactly; upload_finish expects the
// same block count returned by upload_prepare.
for seq := 0; seq < session.BlockNum; seq++ {
Expand All @@ -198,12 +207,12 @@
chunkSize = remaining
}

n, readErr := io.ReadFull(f, buffer[:int(chunkSize)])
n, readErr := io.ReadFull(r, buffer[:int(chunkSize)])
if readErr != nil {
return output.ErrValidation("cannot read file: %s", readErr)
}

if err = uploadDriveMediaMultipartPart(runtime, session.UploadID, seq, buffer[:n]); err != nil {
if err := uploadDriveMediaMultipartPart(runtime, session.UploadID, seq, buffer[:n]); err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n)))
Expand Down
92 changes: 92 additions & 0 deletions shortcuts/common/drive_media_upload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,98 @@ func TestUploadDriveMediaAllBuildsMultipartBody(t *testing.T) {
}
}

func TestUploadDriveMediaAllWithInMemoryContent(t *testing.T) {
// When Content is provided, FilePath is ignored — the in-memory reader
// is streamed directly into the multipart form. Used by the clipboard
// upload path.
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())

uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_mem_123"},
},
}
reg.Register(uploadStub)

payload := []byte{0x89, 0x50, 0x4e, 0x47, 0xde, 0xad}
fileToken, err := UploadDriveMediaAll(runtime, DriveMediaUploadAllConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err != nil {
t.Fatalf("UploadDriveMediaAll() error: %v", err)
}
if fileToken != "file_mem_123" {
t.Fatalf("fileToken = %q, want %q", fileToken, "file_mem_123")
}

body := decodeCapturedDriveMediaMultipartBody(t, uploadStub)
if got := body.Fields["file_name"]; got != "clipboard.png" {
t.Fatalf("file_name = %q, want %q", got, "clipboard.png")
}
if got := body.Files["file"]; !bytes.Equal(got, payload) {
t.Fatalf("uploaded file bytes mismatch; got %v, want %v", got, payload)
}
}

func TestUploadDriveMediaMultipartWithInMemoryContent(t *testing.T) {
// Clipboard multipart upload: Content reader replaces FilePath, and the
// server-declared block plan is honored exactly.
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())

size := MaxDriveMediaUploadSinglePartSize + 1
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_mem_1",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
for i := 0; i < 6; i++ {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_mem_multi"},
},
})

payload := bytes.Repeat([]byte{0xAB}, int(size))
fileToken, err := UploadDriveMediaMultipart(runtime, DriveMediaMultipartUploadConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: size,
ParentType: "docx_image",
ParentNode: "",
})
if err != nil {
t.Fatalf("UploadDriveMediaMultipart() error: %v", err)
}
if fileToken != "file_mem_multi" {
t.Fatalf("fileToken = %q, want %q", fileToken, "file_mem_multi")
}
}

func TestUploadDriveMediaMultipartBuildsPreparePartsAndFinish(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
Expand Down
20 changes: 20 additions & 0 deletions shortcuts/common/testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/spf13/cobra"

"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)

Expand Down Expand Up @@ -37,3 +38,22 @@ func TestNewRuntimeContextWithBotInfo(cmd *cobra.Command, cfg *core.CliConfig, i
})
return rctx
}

// TestNewRuntimeContextForAPI creates a RuntimeContext ready for HTTP tests:
// sets Cmd, Config, Factory, context, and the requested identity so callers
// can invoke DoAPI / CallAPI directly without wiring through a cobra parent
// command.
//
// Pass core.AsBot or core.AsUser explicitly — exposing the identity as a
// parameter keeps the helper reusable for tests that need to exercise the
// user-identity code path (token store, auth login, etc.) without forking
// into a second near-identical helper.
func TestNewRuntimeContextForAPI(ctx context.Context, cmd *cobra.Command, cfg *core.CliConfig, f *cmdutil.Factory, as core.Identity) *RuntimeContext {
return &RuntimeContext{
ctx: ctx,
Cmd: cmd,
Config: cfg,
Factory: f,
resolvedAs: as,
}
}
50 changes: 50 additions & 0 deletions shortcuts/common/testing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package common

import (
"context"
"testing"

"github.com/spf13/cobra"

"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)

func TestTestNewRuntimeContextForAPIWiresFields(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{AppID: "self-test-app", AppSecret: "secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
cmd := &cobra.Command{Use: "testing-helper"}

ctx := context.Background()
rctx := TestNewRuntimeContextForAPI(ctx, cmd, cfg, f, core.AsBot)
if rctx == nil {
t.Fatal("TestNewRuntimeContextForAPI returned nil")
}
if rctx.Cmd != cmd {
t.Errorf("Cmd not wired")
}
if rctx.Config != cfg {
t.Errorf("Config not wired")
}
if rctx.Factory != f {
t.Errorf("Factory not wired")
}
if !rctx.resolvedAs.IsBot() {
t.Errorf("resolvedAs not set to bot, got %q", rctx.resolvedAs)
}
if rctx.Ctx() != ctx {
t.Errorf("ctx not wired")
}

// User identity should also be accepted — the whole reason for making
// the parameter explicit is to let user-identity code paths use this
// helper instead of forking a second one.
userRctx := TestNewRuntimeContextForAPI(ctx, cmd, cfg, f, core.AsUser)
if userRctx.resolvedAs != core.AsUser {
t.Errorf("resolvedAs AsUser not preserved, got %q", userRctx.resolvedAs)
}
}
Loading
Loading