Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ jobs:
- name: Run tests
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... ./extension/...

windows-compat:
needs: fast-gate
runs-on: windows-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python scripts/fetch_meta.py
- name: Run Windows compatibility tests
run: go test -count=1 -timeout=5m . ./shortcuts/doc/...

lint:
needs: fast-gate
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
github.com/spf13/pflag v1.0.9
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0
github.com/yuin/goldmark v1.7.16
github.com/zalando/go-keyring v0.2.8
golang.org/x/net v0.33.0
golang.org/x/sync v0.15.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
Expand Down
128 changes: 128 additions & 0 deletions shortcuts/doc/docs_script.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package doc

import (
"context"
"strings"

"github.com/spf13/cobra"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/doc/internal/docxparse"
)

const (
docsScriptParse = "parse"
docsScriptMarkdownToXML = "markdown-to-xml"
)

var DocsScript = common.Shortcut{
Service: "docs",
Command: "+script",
Description: "Parse and profile XML or Markdown, or convert Markdown to LarkOpenCLI XML",
Risk: "read",
AuthTypes: []string{"user", "bot"},
Scopes: []string{},
Flags: []common.Flag{
{
Name: "command",
Desc: "local document operation",
Required: true,
Enum: []string{docsScriptParse, docsScriptMarkdownToXML},
},
{
Name: "content",
Desc: "document content; use @relative-file or - for stdin",
Required: true,
Input: []string{common.File, common.Stdin},
},
},
Tips: []string{
"parse auto-detects XML or Markdown and returns only the text and block profile",
"markdown-to-xml converts Markdown to LarkOpenCLI XML",
},
PostMount: installDocsScriptHelp,
Validate: validateDocsScript,
DryRun: dryRunDocsScript,
Execute: executeDocsScript,
}

type docsScriptParseResult struct {
Profile docsScriptPublicProfile `json:"profile"`
}

// docsScriptPublicProfile is the stable shortcut response. The parser keeps
// the more detailed breakdown internally so it can be exposed later without
// changing the counting implementation.
type docsScriptPublicProfile struct {
WordCount int `json:"word_count"`
CharCount int `json:"char_count"`
BlockCount int `json:"block_count"`
Blocks []docxparse.BlockShare `json:"blocks"`
}

type docsScriptMarkdownResult struct {
XML string `json:"xml"`
}

func installDocsScriptHelp(cmd *cobra.Command) {
installDocsShortcutHelp("+script")(cmd)
cmd.Example = ` lark-cli docs +script --command parse --content "@draft.xml"
lark-cli docs +script --command parse --content "@draft.md"
lark-cli docs +script --command markdown-to-xml --content "@draft.md"`
}

func validateDocsScript(_ context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("content")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content cannot be empty").
WithParam("--content")

Check warning on line 81 in shortcuts/doc/docs_script.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_script.go#L80-L81

Added lines #L80 - L81 were not covered by tests
}
return nil
}

func dryRunDocsScript(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Local LarkOpenCLI document parsing or conversion; no API call is made").
Set("command", runtime.Str("command")).
Set("input_bytes", len(runtime.Str("content"))).
Set("network", false)
}

func executeDocsScript(_ context.Context, runtime *common.RuntimeContext) error {
command := runtime.Str("command")
content := runtime.Str("content")
switch command {
case docsScriptParse:
profile, err := docxparse.ParseAuto(content)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"could not parse --content as LarkOpenCLI XML or Markdown: %s", err).
WithParam("--content").
WithCause(err)
}
runtime.OutFormatRaw(docsScriptParseResult{Profile: docsScriptPublicProfile{
WordCount: profile.WordCount,
CharCount: profile.CharCount,
BlockCount: profile.BlockCount,
Blocks: profile.Blocks,
}}, nil, nil)
return nil
case docsScriptMarkdownToXML:
xml, err := docxparse.MarkdownToXML(content)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"could not convert --content from Markdown to LarkOpenCLI XML: %s", err).
WithParam("--content").
WithCause(err)

Check warning on line 119 in shortcuts/doc/docs_script.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_script.go#L116-L119

Added lines #L116 - L119 were not covered by tests
}
runtime.OutFormatRaw(docsScriptMarkdownResult{XML: xml}, nil, nil)
return nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported --command %q", command).
WithParam("--command")

Check warning on line 126 in shortcuts/doc/docs_script.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_script.go#L123-L126

Added lines #L123 - L126 were not covered by tests
}
}
209 changes: 209 additions & 0 deletions shortcuts/doc/docs_script_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package doc

import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"

"github.com/spf13/cobra"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/doc/internal/docxparse"
)

func TestDocsScriptParsesAndProfilesXML(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-test"))
source := `<title>标题</title><p>一个苹果是 an apple。</p>`

err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptParse,
"--content", source,
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute docs +script: %v", err)
}

var envelope struct {
OK bool `json:"ok"`
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout)
}
if !envelope.OK {
t.Fatalf("ok = false: %s", stdout)
}
if len(envelope.Data) != 1 || envelope.Data["profile"] == nil {
t.Fatalf("data = %+v, want only profile", envelope.Data)
}
var profile docsScriptPublicProfile
if err := json.Unmarshal(envelope.Data["profile"], &profile); err != nil {
t.Fatalf("decode profile: %v", err)
}
var profileFields map[string]json.RawMessage
if err := json.Unmarshal(envelope.Data["profile"], &profileFields); err != nil {
t.Fatalf("decode profile fields: %v", err)
}
if len(profileFields) != 4 || profileFields["breakdown"] != nil {
t.Fatalf("profile fields = %+v, want breakdown hidden", profileFields)
}
if profile.WordCount != 10 || profile.CharCount != 15 || profile.BlockCount != 2 {
t.Fatalf("profile = %+v", profile)
}
if got := blockCount(profile.Blocks, "title"); got != 1 {
t.Fatalf("title count = %d, want 1", got)
}
if got := blockCount(profile.Blocks, "p"); got != 1 {
t.Fatalf("p count = %d, want 1", got)
}
}

func TestDocsScriptParseAutoDetectsMarkdown(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-auto-markdown"))

err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptParse,
"--content", "# 标题\n\n- item",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute docs +script: %v", err)
}

var envelope struct {
Data docsScriptParseResult `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout)
}
if envelope.Data.Profile.BlockCount != 3 {
t.Fatalf("profile = %+v, want 3 blocks", envelope.Data.Profile)
}
}

func TestDocsScriptConvertsMarkdownFromStdin(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-markdown"))
f.IOStreams.In = bytes.NewBufferString("# 标题\n\n- item")

err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptMarkdownToXML,
"--content", "-",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute docs +script: %v", err)
}
if !strings.Contains(stdout.String(), `<h1>标题</h1><ul><li>item</li></ul>`) {
t.Fatalf("stdout missing converted XML: %s", stdout)
}
var envelope struct {
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout)
}
if len(envelope.Data) != 1 || envelope.Data["xml"] == nil {
t.Fatalf("data = %+v, want only xml", envelope.Data)
}
}

func TestDocsScriptDryRunHasNoAPICall(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-dry-run"))

err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptParse,
"--content", `<p>text</p>`,
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute docs +script dry-run: %v", err)
}
var got struct {
API []any `json:"api"`
Command string `json:"command"`
Network bool `json:"network"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run stdout: %v\n%s", err, stdout)
}
if len(got.API) != 0 || got.Command != docsScriptParse || got.Network {
t.Fatalf("dry-run output = %+v", got)
}
}

func TestDocsScriptReturnsTypedParseError(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-error"))

err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptParse,
"--content", `<!DOCTYPE document><p>text</p>`,
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected parse error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %+v, ok=%v", problem, ok)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--content" {
t.Fatalf("error = %#v, want --content metadata", err)
}
}

func TestDocsScriptRejectsMalformedXML(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-malformed"))

err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptParse,
"--content", `<p>text`,
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected malformed XML error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %+v, ok=%v", problem, ok)
}
}

func TestDocsScriptHelpExamplesAreCrossShellSafe(t *testing.T) {
cmd := &cobra.Command{Short: "local document parser"}
installDocsScriptHelp(cmd)
if strings.Contains(cmd.Example, "cat ") {
t.Fatalf("help examples require a platform-specific command: %q", cmd.Example)
}
if strings.Contains(cmd.Example, "--content @") {
t.Fatalf("help examples contain an unquoted @file argument: %q", cmd.Example)
}
for _, want := range []string{`--content "@draft.xml"`, `--content "@draft.md"`} {
if !strings.Contains(cmd.Example, want) {
t.Errorf("help examples missing %q: %q", want, cmd.Example)
}
}
}

func blockCount(blocks []docxparse.BlockShare, typ string) int {
for _, block := range blocks {
if block.Type == typ {
return block.Count
}
}
return 0
}
Loading
Loading