From fa45e1c7e45de3e3403b3ae2a9743f8de74a501e Mon Sep 17 00:00:00 2001 From: wangweiming-01 Date: Wed, 20 May 2026 12:20:51 +0800 Subject: [PATCH] feat: add markdown +diff shortcut (#876) * feat: add markdown +diff shortcut Change-Id: I7da27889517707ac6f1d5e8c429e4bdfb49fdcf8 * fix: harden markdown diff downloads Change-Id: I0020e14ebee780617d790836af1368db851b8cf1 * refactor: address markdown diff review feedback Change-Id: I0ddb852218ec4784c0f9491896796c3007f04122 --- go.mod | 1 + go.sum | 15 +- shortcuts/markdown/helpers.go | 28 +- shortcuts/markdown/markdown_diff.go | 540 ++++++++++++++++++ shortcuts/markdown/markdown_diff_test.go | 379 ++++++++++++ shortcuts/markdown/markdown_test.go | 2 +- shortcuts/markdown/shortcuts.go | 1 + shortcuts/register_markdown_test.go | 1 + skills/lark-drive/SKILL.md | 1 + skills/lark-markdown/SKILL.md | 7 +- .../references/lark-markdown-diff.md | 156 +++++ .../cli_e2e/markdown/markdown_dryrun_test.go | 56 ++ .../markdown/markdown_workflow_test.go | 54 ++ 13 files changed, 1236 insertions(+), 5 deletions(-) create mode 100644 shortcuts/markdown/markdown_diff.go create mode 100644 shortcuts/markdown/markdown_diff_test.go create mode 100644 skills/lark-markdown/references/lark-markdown-diff.md diff --git a/go.mod b/go.mod index 7862e24ea..98c6d693e 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/google/uuid v1.6.0 github.com/itchyny/gojq v0.12.17 github.com/larksuite/oapi-sdk-go/v3 v3.5.4 + github.com/sergi/go-diff v1.4.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/smartystreets/goconvey v1.8.1 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index e6026a683..0e68ff05a 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,7 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -73,6 +74,11 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0= github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -97,6 +103,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= @@ -107,8 +115,10 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -163,7 +173,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/shortcuts/markdown/helpers.go b/shortcuts/markdown/helpers.go index 0ba09ea33..9d3017710 100644 --- a/shortcuts/markdown/helpers.go +++ b/shortcuts/markdown/helpers.go @@ -137,11 +137,19 @@ func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, f ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)), }) if err != nil { - return nil, output.ErrNetwork("download failed: %s", err) + return nil, wrapMarkdownDownloadError(err) } return resp, nil } +func wrapMarkdownDownloadError(err error) error { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return err + } + return output.ErrNetwork("download failed: %s", err) +} + func validateNonEmptyMarkdownSize(size int64) error { if size == 0 { return output.ErrValidation("%s", markdownEmptyContentError) @@ -170,6 +178,24 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec) return size, nil } +func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (*http.Response, string, error) { + req := &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)), + } + if strings.TrimSpace(version) != "" { + req.QueryParams = larkcore.QueryParams{ + "version": []string{strings.TrimSpace(version)}, + } + } + + resp, err := runtime.DoAPIStream(ctx, req) + if err != nil { + return nil, "", wrapMarkdownDownloadError(err) + } + return resp, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil +} + func markdownDryRunFileField(spec markdownUploadSpec) string { if spec.FilePath != "" { return "@" + spec.FilePath diff --git a/shortcuts/markdown/markdown_diff.go b/shortcuts/markdown/markdown_diff.go new file mode 100644 index 000000000..21d5ca337 --- /dev/null +++ b/shortcuts/markdown/markdown_diff.go @@ -0,0 +1,540 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "context" + "errors" + "fmt" + "io" + "regexp" + "strings" + "time" + + "github.com/sergi/go-diff/diffmatchpatch" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + markdownDiffModeRemoteVsRemote = "remote_vs_remote" + markdownDiffModeRemoteVsLocal = "remote_vs_local" + markdownDiffMaxContentBytes = 10 * 1024 * 1024 + markdownDiffTimeout = 30 * time.Second +) + +var markdownDiffVersionRe = regexp.MustCompile(`^\d{1,19}$`) + +type markdownDiffSpec struct { + FileToken string + FromVersion string + ToVersion string + FilePath string + ContextLines int + Format string +} + +type markdownDiffHunk struct { + Header string `json:"header"` + OldStart int `json:"old_start"` + OldLines int `json:"old_lines"` + NewStart int `json:"new_start"` + NewLines int `json:"new_lines"` +} + +type markdownDiffLineKind int + +const ( + markdownDiffLineEqual markdownDiffLineKind = iota + markdownDiffLineDelete + markdownDiffLineInsert +) + +type markdownDiffLineOp struct { + Kind markdownDiffLineKind + Content string +} + +type markdownDiffHunkRange struct { + Start int + End int +} + +func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffSpec) error { + if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { + return output.ErrValidation("%s", err) + } + if spec.FromVersion != "" { + if err := validateMarkdownDiffVersionValue(spec.FromVersion, "--from-version"); err != nil { + return err + } + } + if spec.ToVersion != "" { + if err := validateMarkdownDiffVersionValue(spec.ToVersion, "--to-version"); err != nil { + return err + } + } + if spec.FilePath != "" { + if _, err := validate.SafeInputPath(spec.FilePath); err != nil { + return output.ErrValidation("unsafe file path: %s", err) + } + if err := validateMarkdownFileName(spec.FilePath, "--file"); err != nil { + return err + } + } + if spec.ContextLines < 0 { + return output.ErrValidation("--context-lines must be >= 0") + } + if spec.Format != "" && spec.Format != "json" && spec.Format != "pretty" { + return output.ErrValidation("markdown +diff only supports --format json or pretty") + } + if spec.FilePath == "" { + if spec.FromVersion == "" && spec.ToVersion == "" { + return common.FlagErrorf("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff") + } + if spec.FromVersion == "" && spec.ToVersion != "" { + return common.FlagErrorf("--to-version requires --from-version") + } + return nil + } + if spec.ToVersion != "" { + return common.FlagErrorf("--to-version is not supported together with --file") + } + return nil +} + +func validateMarkdownDiffVersionValue(value, flagName string) error { + value = strings.TrimSpace(value) + if value == "" { + return output.ErrValidation("%s cannot be empty", flagName) + } + if !markdownDiffVersionRe.MatchString(value) { + return output.ErrValidation("%s must be a numeric version string", flagName) + } + return nil +} + +func markdownDiffMode(spec markdownDiffSpec) string { + if spec.FilePath != "" { + return markdownDiffModeRemoteVsLocal + } + return markdownDiffModeRemoteVsRemote +} + +func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI { + dry := common.NewDryRunAPI().Desc("Download the requested Markdown content, compute a unified diff locally, and print the result without modifying the remote file") + switch markdownDiffMode(spec) { + case markdownDiffModeRemoteVsLocal: + if spec.FromVersion != "" { + dry.GET("/open-apis/drive/v1/files/:file_token/download"). + Desc("[1] Download the specified remote Markdown version"). + Set("file_token", spec.FileToken). + Params(map[string]interface{}{"version": spec.FromVersion}) + } else { + dry.GET("/open-apis/drive/v1/files/:file_token/download"). + Desc("[1] Download the latest remote Markdown version"). + Set("file_token", spec.FileToken) + } + dry.Set("local_file", spec.FilePath) + dry.Set("mode", markdownDiffModeRemoteVsLocal) + default: + dry.GET("/open-apis/drive/v1/files/:file_token/download"). + Desc("[1] Download the base remote Markdown version"). + Set("file_token", spec.FileToken). + Params(map[string]interface{}{"version": spec.FromVersion}) + if spec.ToVersion != "" { + dry.GET("/open-apis/drive/v1/files/:file_token/download"). + Desc("[2] Download the target remote Markdown version"). + Set("file_token", spec.FileToken). + Params(map[string]interface{}{"version": spec.ToVersion}) + } else { + dry.GET("/open-apis/drive/v1/files/:file_token/download"). + Desc("[2] Download the latest remote Markdown version"). + Set("file_token", spec.FileToken) + } + dry.Set("mode", markdownDiffModeRemoteVsRemote) + } + dry.Set("context_lines", spec.ContextLines) + return dry +} + +func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (string, string, error) { + resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + payload, err := readMarkdownDiffPayload(resp.Body, "remote Markdown content") + if err != nil { + return "", "", wrapMarkdownDownloadError(err) + } + return fileName, string(payload), nil +} + +func readMarkdownLocalFile(runtime *common.RuntimeContext, filePath string) (string, error) { + f, err := runtime.FileIO().Open(filePath) + if err != nil { + return "", common.WrapInputStatError(err) + } + defer f.Close() + + payload, err := readMarkdownDiffPayload(f, "local Markdown file") + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return "", err + } + return "", output.ErrValidation("cannot read file: %s", err) + } + return string(payload), nil +} + +func readMarkdownDiffPayload(r io.Reader, source string) ([]byte, error) { + payload, err := io.ReadAll(io.LimitReader(r, markdownDiffMaxContentBytes+1)) + if err != nil { + return nil, err + } + if len(payload) > markdownDiffMaxContentBytes { + return nil, output.ErrValidation("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes)) + } + return payload, nil +} + +func splitMarkdownDiffLines(text string) []string { + if text == "" { + return nil + } + lines := strings.SplitAfter(text, "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +func markdownDiffLineOps(fromContent, toContent string) []markdownDiffLineOp { + dmp := diffmatchpatch.New() + dmp.DiffTimeout = markdownDiffTimeout + before, after, lineArray := dmp.DiffLinesToRunes(fromContent, toContent) + diffs := dmp.DiffMainRunes(before, after, false) + // Keep the diff line-based. Running cleanup after hydrating real text + // would re-split replacements into word-level edits. + diffs = dmp.DiffCharsToLines(diffs, lineArray) + + ops := make([]markdownDiffLineOp, 0, len(diffs)) + for _, diff := range diffs { + lines := splitMarkdownDiffLines(diff.Text) + for _, line := range lines { + switch diff.Type { + case diffmatchpatch.DiffDelete: + ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineDelete, Content: line}) + case diffmatchpatch.DiffInsert: + ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineInsert, Content: line}) + default: + ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineEqual, Content: line}) + } + } + } + return ops +} + +func markdownDiffSummary(ops []markdownDiffLineOp) (bool, int, int) { + added := 0 + deleted := 0 + changed := false + for _, op := range ops { + switch op.Kind { + case markdownDiffLineDelete: + changed = true + deleted++ + case markdownDiffLineInsert: + changed = true + added++ + } + } + return changed, added, deleted +} + +func markdownDiffHunkRanges(ops []markdownDiffLineOp, contextLines int) []markdownDiffHunkRange { + if len(ops) == 0 { + return nil + } + changedLines := make([]int, 0) + for i, op := range ops { + if op.Kind != markdownDiffLineEqual { + changedLines = append(changedLines, i) + } + } + if len(changedLines) == 0 { + return nil + } + + ranges := make([]markdownDiffHunkRange, 0, len(changedLines)) + current := markdownDiffHunkRange{ + Start: max(0, changedLines[0]-contextLines), + End: min(len(ops), changedLines[0]+contextLines+1), + } + for _, idx := range changedLines[1:] { + next := markdownDiffHunkRange{ + Start: max(0, idx-contextLines), + End: min(len(ops), idx+contextLines+1), + } + if next.Start <= current.End { + if next.End > current.End { + current.End = next.End + } + continue + } + ranges = append(ranges, current) + current = next + } + ranges = append(ranges, current) + return ranges +} + +func markdownDiffHunkAt(ops []markdownDiffLineOp, r markdownDiffHunkRange) markdownDiffHunk { + oldBefore := 0 + newBefore := 0 + for _, op := range ops[:r.Start] { + if op.Kind != markdownDiffLineInsert { + oldBefore++ + } + if op.Kind != markdownDiffLineDelete { + newBefore++ + } + } + + oldLines := 0 + newLines := 0 + for _, op := range ops[r.Start:r.End] { + if op.Kind != markdownDiffLineInsert { + oldLines++ + } + if op.Kind != markdownDiffLineDelete { + newLines++ + } + } + + oldStart := oldBefore + 1 + newStart := newBefore + 1 + if oldLines == 0 { + oldStart = oldBefore + } + if newLines == 0 { + newStart = newBefore + } + + return markdownDiffHunk{ + Header: fmt.Sprintf("@@ -%d,%d +%d,%d @@", oldStart, oldLines, newStart, newLines), + OldStart: oldStart, + OldLines: oldLines, + NewStart: newStart, + NewLines: newLines, + } +} + +func buildMarkdownUnifiedDiff(fromLabel, toLabel string, ops []markdownDiffLineOp, ranges []markdownDiffHunkRange) string { + if len(ranges) == 0 { + return "" + } + + var b strings.Builder + fmt.Fprintf(&b, "--- %s\n", fromLabel) + fmt.Fprintf(&b, "+++ %s\n", toLabel) + for _, r := range ranges { + hunk := markdownDiffHunkAt(ops, r) + b.WriteString(hunk.Header) + b.WriteByte('\n') + for _, op := range ops[r.Start:r.End] { + prefix := ' ' + switch op.Kind { + case markdownDiffLineDelete: + prefix = '-' + case markdownDiffLineInsert: + prefix = '+' + } + b.WriteByte(byte(prefix)) + b.WriteString(op.Content) + if !strings.HasSuffix(op.Content, "\n") { + b.WriteByte('\n') + b.WriteString(`\ No newline at end of file`) + b.WriteByte('\n') + } + } + } + return b.String() +} + +func summarizeMarkdownDiff(fromLabel, toLabel, fromContent, toContent string, contextLines int) (string, bool, int, int, []markdownDiffHunk) { + ops := markdownDiffLineOps(fromContent, toContent) + changed, added, deleted := markdownDiffSummary(ops) + ranges := markdownDiffHunkRanges(ops, contextLines) + hunks := make([]markdownDiffHunk, 0, len(ranges)) + for _, r := range ranges { + hunks = append(hunks, markdownDiffHunkAt(ops, r)) + } + return buildMarkdownUnifiedDiff(fromLabel, toLabel, ops, ranges), changed, added, deleted, hunks +} + +func colorizeUnifiedDiff(diffText string) string { + if diffText == "" { + return "" + } + lines := strings.SplitAfter(diffText, "\n") + var b strings.Builder + for _, line := range lines { + trimmed := strings.TrimRight(line, "\n") + suffix := "" + if strings.HasSuffix(line, "\n") { + suffix = "\n" + } + switch { + case strings.HasPrefix(trimmed, "@@"): + b.WriteString(output.Cyan) + b.WriteString(trimmed) + b.WriteString(output.Reset) + case strings.HasPrefix(trimmed, "+++"), strings.HasPrefix(trimmed, "---"): + b.WriteString(output.Bold) + b.WriteString(trimmed) + b.WriteString(output.Reset) + case strings.HasPrefix(trimmed, "+") && !strings.HasPrefix(trimmed, "+++"): + b.WriteString(output.Green) + b.WriteString(trimmed) + b.WriteString(output.Reset) + case strings.HasPrefix(trimmed, "-") && !strings.HasPrefix(trimmed, "---"): + b.WriteString(output.Red) + b.WriteString(trimmed) + b.WriteString(output.Reset) + default: + b.WriteString(trimmed) + } + b.WriteString(suffix) + } + return b.String() +} + +func prettyPrintMarkdownDiff(w io.Writer, data map[string]interface{}) { + if !common.GetBool(data, "changed") { + io.WriteString(w, "No differences.\n") + return + } + io.WriteString(w, colorizeUnifiedDiff(common.GetString(data, "diff"))) +} + +var MarkdownDiff = common.Shortcut{ + Service: "markdown", + Command: "+diff", + Description: "Compare remote Markdown versions or compare remote Markdown against a local file", + Risk: "read", + Scopes: []string{"drive:file:download"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "file-token", Desc: "target Markdown file token", Required: true}, + {Name: "from-version", Desc: "base remote version; when --to-version is omitted, compare this version to the latest remote version"}, + {Name: "to-version", Desc: "target remote version; requires --from-version"}, + {Name: "file", Desc: "local .md file path to compare against the remote content"}, + {Name: "context-lines", Desc: "number of unchanged context lines to include around each diff hunk", Type: "int", Default: "3"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateMarkdownDiffSpec(runtime, markdownDiffSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + FromVersion: strings.TrimSpace(runtime.Str("from-version")), + ToVersion: strings.TrimSpace(runtime.Str("to-version")), + FilePath: strings.TrimSpace(runtime.Str("file")), + ContextLines: runtime.Int("context-lines"), + Format: runtime.Format, + }) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return markdownDiffDryRun(markdownDiffSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + FromVersion: strings.TrimSpace(runtime.Str("from-version")), + ToVersion: strings.TrimSpace(runtime.Str("to-version")), + FilePath: strings.TrimSpace(runtime.Str("file")), + ContextLines: runtime.Int("context-lines"), + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec := markdownDiffSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + FromVersion: strings.TrimSpace(runtime.Str("from-version")), + ToVersion: strings.TrimSpace(runtime.Str("to-version")), + FilePath: strings.TrimSpace(runtime.Str("file")), + ContextLines: runtime.Int("context-lines"), + } + + var ( + fromLabel string + toLabel string + fromContent string + toContent string + err error + ) + + switch markdownDiffMode(spec) { + case markdownDiffModeRemoteVsLocal: + fromLabel = "a/" + spec.FileToken + if spec.FromVersion != "" { + fromLabel += "@version:" + spec.FromVersion + } else { + fromLabel += "@latest" + } + _, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion) + if err != nil { + return err + } + + toLabel = "b/" + spec.FilePath + toContent, err = readMarkdownLocalFile(runtime, spec.FilePath) + if err != nil { + return err + } + default: + fromLabel = "a/" + spec.FileToken + "@version:" + spec.FromVersion + _, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion) + if err != nil { + return err + } + + if spec.ToVersion != "" { + toLabel = "b/" + spec.FileToken + "@version:" + spec.ToVersion + _, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion) + } else { + toLabel = "b/" + spec.FileToken + "@latest" + _, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "") + } + if err != nil { + return err + } + } + + diffText, changed, addedLines, deletedLines, hunks := summarizeMarkdownDiff(fromLabel, toLabel, fromContent, toContent, spec.ContextLines) + + out := map[string]interface{}{ + "changed": changed, + "mode": markdownDiffMode(spec), + "file_token": spec.FileToken, + "from_version": spec.FromVersion, + "to_version": spec.ToVersion, + "from_label": fromLabel, + "to_label": toLabel, + "added_lines": addedLines, + "deleted_lines": deletedLines, + "context_lines": spec.ContextLines, + "hunks": hunks, + "diff": diffText, + } + if spec.FilePath != "" { + out["local_file"] = spec.FilePath + } + + runtime.OutFormatRaw(out, nil, func(w io.Writer) { + prettyPrintMarkdownDiff(w, out) + }) + return nil + }, +} diff --git a/shortcuts/markdown/markdown_diff_test.go b/shortcuts/markdown/markdown_diff_test.go new file mode 100644 index 000000000..b870778e4 --- /dev/null +++ b/shortcuts/markdown/markdown_diff_test.go @@ -0,0 +1,379 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" +) + +func TestMarkdownDiffRejectsUnsupportedFormat(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig()) + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--from-version", "7633658129540910621", + "--format", "table", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "only supports --format json or pretty") { + t.Fatalf("expected format validation error, got %v", err) + } +} + +func TestMarkdownDiffRejectsToVersionWithoutFromVersion(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig()) + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--to-version", "7633658129540910628", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--to-version requires --from-version") { + t.Fatalf("expected version validation error, got %v", err) + } +} + +func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("# Title\n\n- alpha\n- beta\n"), + Headers: http.Header{ + "Content-Disposition": []string{`attachment; filename="README.md"`}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628", + Status: 200, + RawBody: []byte("# Title\n\n- alpha\n- beta updated\n- gamma\n"), + Headers: http.Header{ + "Content-Disposition": []string{`attachment; filename="README.md"`}, + }, + }) + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--from-version", "7633658129540910621", + "--to-version", "7633658129540910628", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env struct { + OK bool `json:"ok"` + Data struct { + Changed bool `json:"changed"` + Mode string `json:"mode"` + FromVersion string `json:"from_version"` + ToVersion string `json:"to_version"` + AddedLines int `json:"added_lines"` + DeletedLines int `json:"deleted_lines"` + Diff string `json:"diff"` + Hunks []markdownDiffHunk `json:"hunks"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("json unmarshal error: %v\n%s", err, stdout.String()) + } + if !env.OK { + t.Fatalf("expected ok=true, got false: %s", stdout.String()) + } + if !env.Data.Changed { + t.Fatalf("expected changed=true: %s", stdout.String()) + } + if env.Data.Mode != markdownDiffModeRemoteVsRemote { + t.Fatalf("mode = %q, want %q", env.Data.Mode, markdownDiffModeRemoteVsRemote) + } + if env.Data.FromVersion != "7633658129540910621" || env.Data.ToVersion != "7633658129540910628" { + t.Fatalf("versions = %q -> %q", env.Data.FromVersion, env.Data.ToVersion) + } + if env.Data.AddedLines != 2 || env.Data.DeletedLines != 1 { + t.Fatalf("added/deleted = %d/%d, want 2/1", env.Data.AddedLines, env.Data.DeletedLines) + } + if len(env.Data.Hunks) != 1 { + t.Fatalf("len(hunks) = %d, want 1", len(env.Data.Hunks)) + } + if !strings.Contains(env.Data.Diff, "@@") || !strings.Contains(env.Data.Diff, "+- gamma") { + t.Fatalf("diff missing expected content: %s", env.Data.Diff) + } +} + +func TestMarkdownDiffRemoteVsLocalPretty(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download", + Status: 200, + RawBody: []byte("# Title\n\nhello old\n"), + Headers: http.Header{ + "Content-Disposition": []string{`attachment; filename="README.md"`}, + }, + }) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + if err := os.WriteFile("local.md", []byte("# Title\n\nhello new\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--file", "./local.md", + "--format", "pretty", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "@@") { + t.Fatalf("pretty output missing hunk header: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), output.Red+"-hello old"+output.Reset) { + t.Fatalf("pretty output missing removed line color: %q", stdout.String()) + } + if !strings.Contains(stdout.String(), output.Green+"+hello new"+output.Reset) { + t.Fatalf("pretty output missing added line color: %q", stdout.String()) + } +} + +func TestMarkdownDiffRejectsOversizedRemoteContent(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download", + Status: 200, + RawBody: bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1), + }) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + if err := os.WriteFile("local.md", []byte("# Title\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--file", "./local.md", + "--as", "bot", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "remote Markdown content exceeds 10.0 MB markdown +diff content limit") { + t.Fatalf("expected remote content size error, got %v", err) + } +} + +func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download", + Status: 200, + RawBody: []byte("# Title\n"), + }) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + if err := os.WriteFile("local.md", bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--file", "./local.md", + "--as", "bot", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "local Markdown file exceeds 10.0 MB markdown +diff content limit") { + t.Fatalf("expected local content size error, got %v", err) + } +} + +func TestMarkdownDownloadErrorPreservesStructuredErrors(t *testing.T) { + apiErr := output.ErrAPI(99991663, "permission denied", map[string]interface{}{"permission": "drive:file:download"}) + if got := wrapMarkdownDownloadError(apiErr); got != apiErr { + t.Fatalf("wrapMarkdownDownloadError() = %v, want original API error", got) + } + + got := wrapMarkdownDownloadError(errors.New("dial tcp timeout")) + var exitErr *output.ExitError + if !errors.As(got, &exitErr) { + t.Fatalf("wrapMarkdownDownloadError() = %T, want *output.ExitError", got) + } + if exitErr.Code != output.ExitNetwork { + t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork) + } + if !strings.Contains(got.Error(), "download failed: dial tcp timeout") { + t.Fatalf("wrapped error = %q", got.Error()) + } +} + +func TestMarkdownDiffIncludesNoNewlineMarker(t *testing.T) { + diffText, changed, added, deleted, hunks := summarizeMarkdownDiff( + "a/test.md", + "b/test.md", + "# Title\n\nhello old", + "# Title\n\nhello new", + 3, + ) + if !changed { + t.Fatalf("expected changed=true") + } + if added != 1 || deleted != 1 { + t.Fatalf("added/deleted = %d/%d, want 1/1", added, deleted) + } + if len(hunks) != 1 { + t.Fatalf("len(hunks) = %d, want 1", len(hunks)) + } + if strings.Count(diffText, "\\ No newline at end of file") != 2 { + t.Fatalf("diff should contain two no-newline markers: %q", diffText) + } + if !strings.Contains(diffText, "-hello old\n\\ No newline at end of file\n+hello new\n\\ No newline at end of file\n") { + t.Fatalf("diff missing expected no-newline marker sequence: %q", diffText) + } +} + +func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("line1\nline2\nline3\nline4\nline5\nline6\n"), + Headers: http.Header{ + "Content-Disposition": []string{`attachment; filename="README.md"`}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628", + Status: 200, + RawBody: []byte("line1\nline2 changed\nline3\nline4\nline5 changed\nline6\n"), + Headers: http.Header{ + "Content-Disposition": []string{`attachment; filename="README.md"`}, + }, + }) + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--from-version", "7633658129540910621", + "--to-version", "7633658129540910628", + "--context-lines", "0", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env struct { + OK bool `json:"ok"` + Data struct { + Changed bool `json:"changed"` + AddedLines int `json:"added_lines"` + DeletedLines int `json:"deleted_lines"` + Hunks []markdownDiffHunk `json:"hunks"` + Diff string `json:"diff"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("json unmarshal error: %v\n%s", err, stdout.String()) + } + if !env.OK || !env.Data.Changed { + t.Fatalf("expected changed=true: %s", stdout.String()) + } + if env.Data.AddedLines != 2 || env.Data.DeletedLines != 2 { + t.Fatalf("added/deleted = %d/%d, want 2/2", env.Data.AddedLines, env.Data.DeletedLines) + } + if len(env.Data.Hunks) != 2 { + t.Fatalf("len(hunks) = %d, want 2", len(env.Data.Hunks)) + } + if !strings.Contains(env.Data.Diff, "-line2") || !strings.Contains(env.Data.Diff, "+line5 changed") { + t.Fatalf("diff missing expected content: %s", env.Data.Diff) + } +} + +func TestMarkdownDiffNoChangesPretty(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("# Title\n"), + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download", + Status: 200, + RawBody: []byte("# Title\n"), + }) + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--from-version", "7633658129540910621", + "--format", "pretty", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := strings.TrimSpace(stdout.String()); got != "No differences." { + t.Fatalf("pretty no-change output = %q, want %q", got, "No differences.") + } +} + +func TestMarkdownDiffDryRunRemoteVsLocal(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig()) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + localPath := filepath.Join(".", "local.md") + if err := os.WriteFile(localPath, []byte("# local\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--file", localPath, + "--dry-run", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/:file_token/download") && !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/box_md_diff/download") { + t.Fatalf("dry-run missing download call: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), `"local_file": "local.md"`) && !strings.Contains(stdout.String(), `"local_file": "./local.md"`) { + t.Fatalf("dry-run missing local file metadata: %s", stdout.String()) + } +} diff --git a/shortcuts/markdown/markdown_test.go b/shortcuts/markdown/markdown_test.go index 71ac32339..66fae9764 100644 --- a/shortcuts/markdown/markdown_test.go +++ b/shortcuts/markdown/markdown_test.go @@ -182,7 +182,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { t.Parallel() got := Shortcuts() - want := []string{"+create", "+fetch", "+patch", "+overwrite"} + want := []string{"+create", "+diff", "+fetch", "+patch", "+overwrite"} if len(got) != len(want) { t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want)) diff --git a/shortcuts/markdown/shortcuts.go b/shortcuts/markdown/shortcuts.go index 95ef9e369..2bbff5578 100644 --- a/shortcuts/markdown/shortcuts.go +++ b/shortcuts/markdown/shortcuts.go @@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common" func Shortcuts() []common.Shortcut { return []common.Shortcut{ MarkdownCreate, + MarkdownDiff, MarkdownFetch, MarkdownPatch, MarkdownOverwrite, diff --git a/shortcuts/register_markdown_test.go b/shortcuts/register_markdown_test.go index 7a622204e..57fa55c3c 100644 --- a/shortcuts/register_markdown_test.go +++ b/shortcuts/register_markdown_test.go @@ -16,6 +16,7 @@ func TestRegisterShortcutsMountsMarkdownCommands(t *testing.T) { for _, path := range [][]string{ {"markdown", "+create"}, + {"markdown", "+diff"}, {"markdown", "+fetch"}, {"markdown", "+overwrite"}, } { diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 1599bd7e0..485a133dd 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -20,6 +20,7 @@ metadata: - 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。 - 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。 - 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../lark-markdown/SKILL.md)。 +- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`。 - 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。 - 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。 - 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。 diff --git a/skills/lark-markdown/SKILL.md b/skills/lark-markdown/SKILL.md index e8d57f7aa..1540ed810 100644 --- a/skills/lark-markdown/SKILL.md +++ b/skills/lark-markdown/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-markdown -version: 1.1.0 -description: "飞书 Markdown:查看、创建、上传和编辑 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取或修改时使用。" +version: 1.2.0 +description: "飞书 Markdown:查看、创建、上传、编辑和比较 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取、修改、局部 patch 或比较差异时使用。" metadata: requires: bins: ["lark-cli"] @@ -15,9 +15,11 @@ metadata: ## 快速决策 - 用户要**上传、创建一个原生 `.md` 文件**,使用 `lark-cli markdown +create` +- 用户要**比较原生 `.md` 文件的历史版本差异**,或比较远端 Markdown 与本地草稿,使用 `lark-cli markdown +diff` - 用户要**读取 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +fetch` - 用户要对 Markdown 文件做**局部文本替换 / 正则替换**,优先使用 `lark-cli markdown +patch` - 用户要**覆盖更新 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +overwrite` +- 用户要先拿 Markdown 文件的历史版本号,再做比较/下载/回滚,先用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +version-history` - 用户要把本地 Markdown **导入成在线新版文档(docx)**,不要用本 skill,改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx` - 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间操作,不要留在本 skill,切到 [`lark-drive`](../lark-drive/SKILL.md) @@ -42,6 +44,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli markdown + [flags]` | Shortcut | 说明 | |----------|------| | [`+create`](references/lark-markdown-create.md) | Create a Markdown file in Drive | +| [`+diff`](references/lark-markdown-diff.md) | Compare two remote Markdown versions, or compare remote Markdown against a local file | | [`+fetch`](references/lark-markdown-fetch.md) | Fetch a Markdown file from Drive | | [`+patch`](references/lark-markdown-patch.md) | Patch a Markdown file in Drive via fetch-local-replace-overwrite | | [`+overwrite`](references/lark-markdown-overwrite.md) | Overwrite an existing Markdown file in Drive | diff --git a/skills/lark-markdown/references/lark-markdown-diff.md b/skills/lark-markdown/references/lark-markdown-diff.md new file mode 100644 index 000000000..5f18b79ae --- /dev/null +++ b/skills/lark-markdown/references/lark-markdown-diff.md @@ -0,0 +1,156 @@ +# markdown +diff + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +比较 Drive 中原生 Markdown 的两个历史版本,或比较远端 Markdown 与本地 `.md` 草稿。需要历史版本号时,先用 [`drive +version-history`](../../lark-drive/references/lark-drive-version-history.md) 获取 `version`,不要使用 `tag`。 + +## 命令 + +```bash +# 比较两个远端版本 +lark-cli markdown +diff \ + --file-token boxcnxxxx \ + --from-version 7633658129540910621 \ + --to-version 7633658129540910628 + +# 比较历史版本与远端最新版本 +lark-cli markdown +diff \ + --file-token boxcnxxxx \ + --from-version 7633658129540910621 + +# 比较远端最新版本与本地草稿 +lark-cli markdown +diff \ + --file-token boxcnxxxx \ + --file ./draft.md \ + --format pretty + +# 比较指定远端版本与本地草稿 +lark-cli markdown +diff \ + --file-token boxcnxxxx \ + --from-version 7633658129540910621 \ + --file ./draft.md + +# 预览底层请求 +lark-cli markdown +diff \ + --file-token boxcnxxxx \ + --from-version 7633658129540910621 \ + --to-version 7633658129540910628 \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 目标 Markdown 文件 token | +| `--from-version` | 否 | 基准远端版本;不传 `--file` 时必填,传 `--file` 时省略表示“远端最新 vs 本地文件” | +| `--to-version` | 否 | 目标远端版本;要求同时传 `--from-version`,且不能与 `--file` 一起使用。省略时表示远端最新版本 | +| `--file` | 否 | 本地 `.md` 文件路径;传入后进入“远端 vs 本地”比较模式 | +| `--context-lines` | 否 | unified diff 每个 hunk 前后保留的上下文行数,默认 `3` | +| `--format` | 否 | 仅支持 `json`(默认)和 `pretty` | + +## 关键行为 + +- `--file` 存在时: + - 省略 `--from-version` = 比较“远端最新版本 vs 本地文件” + - 传入 `--from-version` = 比较“指定远端版本 vs 本地文件” +- `--to-version` 只能用于“远端版本 vs 远端版本”,不能与 `--file` 同时出现 +- `--format pretty` 输出带颜色的 unified diff;`--format json` 返回结构化摘要和完整 diff 文本 +- 无差异时: + - `json` 输出里 `changed=false` + - `pretty` 输出固定为 `No differences.` + +## 返回值 + +```json +{ + "ok": true, + "identity": "user", + "data": { + "changed": true, + "mode": "remote_vs_remote", + "file_token": "boxcnxxxx", + "from_version": "7633658129540910621", + "to_version": "7633658129540910628", + "from_label": "a/boxcnxxxx@version:7633658129540910621", + "to_label": "b/boxcnxxxx@version:7633658129540910628", + "added_lines": 3, + "deleted_lines": 2, + "context_lines": 3, + "hunks": [ + { + "header": "@@ -1,6 +1,7 @@", + "old_start": 1, + "old_lines": 6, + "new_start": 1, + "new_lines": 7 + } + ], + "diff": "--- a/boxcnxxxx@version:7633658129540910621\n+++ b/boxcnxxxx@version:7633658129540910628\n@@ -1,2 +1,2 @@\n..." + } +} +``` + +完整字段说明: + +| 字段 | 层级 | 含义 | +|------|------|------| +| `ok` | 顶层 | CLI 通用成功标记;`true` 表示命令执行成功 | +| `identity` | 顶层 | 本次执行使用的身份,通常是 `user` 或 `bot` | +| `data` | 顶层 | 本次 diff 的业务结果对象 | +| `changed` | `data` | 是否存在差异;`true` 表示两侧内容不同,`false` 表示完全一致 | +| `mode` | `data` | 比较模式;`remote_vs_remote` = 远端对远端,`remote_vs_local` = 远端对本地 | +| `file_token` | `data` | 被比较的远端 Markdown 文件 token | +| `from_version` | `data` | 基准远端版本号;远端最新 vs 本地时可能为空字符串 | +| `to_version` | `data` | 目标远端版本号;当目标侧是远端最新版本或本地文件时通常为空字符串 | +| `from_label` | `data` | unified diff 基准侧标签名,会直接出现在 `diff` 文本的 `---` 头部 | +| `to_label` | `data` | unified diff 目标侧标签名,会直接出现在 `diff` 文本的 `+++` 头部 | +| `added_lines` | `data` | 新增行数统计 | +| `deleted_lines` | `data` | 删除行数统计 | +| `context_lines` | `data` | 每个 hunk 前后保留的上下文行数,对应传入的 `--context-lines` | +| `hunks` | `data` | 结构化的变更块摘要数组;每个元素对应 patch 里的一个 `@@ ... @@` 段 | +| `diff` | `data` | 完整 unified diff 文本;最适合直接阅读或保存 | +| `local_file` | `data` | 仅在 `remote_vs_local` 模式下出现;值就是传给 `--file` 的本地 Markdown 路径 | + +标签字段补充: + +- `from_label` / `to_label` 只用于标识 diff 两侧,不代表额外 API 字段 +- `from_label` 表示基准侧,`to_label` 表示目标侧 +- 远端版本通常形如 `a/@version:`、`b/@version:` +- 当目标侧是远端最新版本时,`to_label` 形如 `b/@latest` +- 当目标侧是本地文件时,`to_label` 形如 `b/./draft.md` + +`hunks` 子字段说明: + +| 字段 | 含义 | +|------|------| +| `header` | 原始 hunk 头,例如 `@@ -3,1 +3,1 @@` | +| `old_start` | 旧内容从第几行开始 | +| `old_lines` | 旧内容这段覆盖多少行 | +| `new_start` | 新内容从第几行开始 | +| `new_lines` | 新内容这段覆盖多少行 | + +补充说明: + +- `hunks` 适合 agent 或脚本快速定位变更范围;完整逐行内容仍以 `diff` 字段为准 +- `changed=false` 时,`hunks` 通常为空数组,`diff` 通常为空字符串;如果使用 `--format pretty`,终端输出会是 `No differences.` + +远端 vs 本地时会额外返回: + +```json +{ + "local_file": "./draft.md" +} +``` + +- `local_file` + - 只有传了 `--file`、进入“远端 vs 本地”模式时才会返回 + - 值就是本次命令实际比较的本地 Markdown 路径,也就是你传给 `--file` 的那个路径 + - 它表示“目标侧本地文件”,不是临时下载文件,也不是远端文件名 + - 如果没有这个字段,说明本次是“远端版本 vs 远端版本” + +## 参考 + +- [lark-markdown](../SKILL.md) — Markdown 域总览 +- [lark-drive-version-history](../../lark-drive/references/lark-drive-version-history.md) — 获取可用于 diff 的历史版本号 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/tests/cli_e2e/markdown/markdown_dryrun_test.go b/tests/cli_e2e/markdown/markdown_dryrun_test.go index 9ba6933be..db783342d 100644 --- a/tests/cli_e2e/markdown/markdown_dryrun_test.go +++ b/tests/cli_e2e/markdown/markdown_dryrun_test.go @@ -96,6 +96,62 @@ func TestMarkdownCreateDryRun_RejectsEmptyContent(t *testing.T) { assert.Contains(t, errMsg, "empty markdown content is not supported") } +func TestMarkdownDiffDryRun_RemoteVsRemote(t *testing.T) { + setMarkdownDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+diff", + "--file-token", "boxcnMarkdownDryRun", + "--from-version", "7633658129540910621", + "--to-version", "7633658129540910628", + "--context-lines", "1", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download") + assert.Contains(t, output, `"mode": "remote_vs_remote"`) + assert.Contains(t, output, `"version": "7633658129540910621"`) + assert.Contains(t, output, `"version": "7633658129540910628"`) + assert.Contains(t, output, `"context_lines": 1`) +} + +func TestMarkdownDiffDryRun_RemoteVsLocal(t *testing.T) { + setMarkdownDryRunConfigEnv(t) + + dir := t.TempDir() + require.NoError(t, os.WriteFile(dir+"/draft.md", []byte("# draft\n"), 0o644)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+diff", + "--file-token", "boxcnMarkdownDryRun", + "--file", "./draft.md", + "--dry-run", + }, + DefaultAs: "bot", + WorkDir: dir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download") + assert.Contains(t, output, `"mode": "remote_vs_local"`) + assert.Contains(t, output, `"local_file": "./draft.md"`) +} + func TestMarkdownFetchDryRun_OutputFile(t *testing.T) { setMarkdownDryRunConfigEnv(t) diff --git a/tests/cli_e2e/markdown/markdown_workflow_test.go b/tests/cli_e2e/markdown/markdown_workflow_test.go index ba0addb25..0f8c52c65 100644 --- a/tests/cli_e2e/markdown/markdown_workflow_test.go +++ b/tests/cli_e2e/markdown/markdown_workflow_test.go @@ -6,10 +6,12 @@ package markdown import ( "context" "os" + "strings" "testing" "time" clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) @@ -126,4 +128,56 @@ func TestMarkdownLifecycleWorkflow(t *testing.T) { fetchUpdatedResult.AssertExitCode(t, 0) fetchUpdatedResult.AssertStdoutStatus(t, true) require.Equal(t, updatedContent, gjson.Get(fetchUpdatedResult.Stdout, "data.content").String(), "stdout:\n%s", fetchUpdatedResult.Stdout) + + historyResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-history", + "--file-token", fileToken, + }, + DefaultAs: "user", + }) + require.NoError(t, err) + historyResult.AssertExitCode(t, 0) + historyResult.AssertStdoutStatus(t, true) + + latestVersion := gjson.Get(overwriteResult.Stdout, "data.version").String() + require.NotEmpty(t, latestVersion, "stdout:\n%s", overwriteResult.Stdout) + + versions := gjson.Get(historyResult.Stdout, "data.versions").Array() + require.GreaterOrEqual(t, len(versions), 2, "stdout:\n%s", historyResult.Stdout) + + var previousVersion string + // version-history returns versions in descending chronological order; + // pick the first non-latest as the previous version. + for _, version := range versions { + candidate := version.Get("version").String() + if candidate != "" && candidate != latestVersion { + previousVersion = candidate + break + } + } + require.NotEmpty(t, previousVersion, "stdout:\n%s", historyResult.Stdout) + + diffResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+diff", + "--file-token", fileToken, + "--from-version", previousVersion, + "--to-version", latestVersion, + }, + DefaultAs: "user", + }) + require.NoError(t, err) + diffResult.AssertExitCode(t, 0) + diffResult.AssertStdoutStatus(t, true) + + assert.True(t, gjson.Get(diffResult.Stdout, "data.changed").Bool(), "stdout:\n%s", diffResult.Stdout) + assert.Equal(t, "remote_vs_remote", gjson.Get(diffResult.Stdout, "data.mode").String(), "stdout:\n%s", diffResult.Stdout) + assert.Equal(t, previousVersion, gjson.Get(diffResult.Stdout, "data.from_version").String(), "stdout:\n%s", diffResult.Stdout) + assert.Equal(t, latestVersion, gjson.Get(diffResult.Stdout, "data.to_version").String(), "stdout:\n%s", diffResult.Stdout) + assert.GreaterOrEqual(t, len(gjson.Get(diffResult.Stdout, "data.hunks").Array()), 1, "stdout:\n%s", diffResult.Stdout) + + diffText := gjson.Get(diffResult.Stdout, "data.diff").String() + assert.True(t, strings.Contains(diffText, "-hello markdown workflow") || strings.Contains(diffText, "-# Initial"), "stdout:\n%s", diffResult.Stdout) + assert.True(t, strings.Contains(diffText, "+new body") || strings.Contains(diffText, "+# Updated"), "stdout:\n%s", diffResult.Stdout) }