mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
1 Commits
sun/tempv2
...
codex/cli-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f9c6d9bfb |
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -99,22 +99,6 @@ 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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,7 +42,6 @@ tests/mail/reports/
|
||||
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
.lark-cli-e2e-test/reports/
|
||||
.lark-slides/
|
||||
/notes/
|
||||
/minutes/
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// BenchmarkBuild_Default measures the per-Build cost for the default
|
||||
// configuration (service commands + shortcuts + plugins + strict mode).
|
||||
// This is the hot-path baseline for repeated Build invocations.
|
||||
func BenchmarkBuild_Default(b *testing.B) {
|
||||
// Warm one-time caches first
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{})
|
||||
runtime.GC()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuild_WithoutServiceCommands measures the Build cost without
|
||||
// service command registration. The delta from Default gives the
|
||||
// service-command registration cost.
|
||||
func BenchmarkBuild_WithoutServiceCommands(b *testing.B) {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands())
|
||||
runtime.GC()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands())
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuild_WithoutPlugins measures the Build cost without plugins.
|
||||
// The delta from Default gives the plugin + policy + hook cost.
|
||||
func BenchmarkBuild_WithoutPlugins(b *testing.B) {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
|
||||
runtime.GC()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuild_WithoutServiceAndPlugins measures the Build cost with
|
||||
// neither service commands nor plugins. This isolates the base cost
|
||||
// (root command + builtins + shortcuts).
|
||||
func BenchmarkBuild_WithoutServiceAndPlugins(b *testing.B) {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands(), WithoutPlugins())
|
||||
runtime.GC()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands(), WithoutPlugins())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuild_CommandTreeStats counts the total number of commands,
|
||||
// runnable commands, and flags in the default build. This gives us
|
||||
// the scale of the command tree to reason about optimization targets.
|
||||
func TestBuild_CommandTreeStats(t *testing.T) {
|
||||
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
|
||||
|
||||
var totalCmds, runnableCmds, groupCmds int
|
||||
var totalFlags int
|
||||
|
||||
var walk func(cmd *cobra.Command)
|
||||
walk = func(cmd *cobra.Command) {
|
||||
totalCmds++
|
||||
if cmd.RunE != nil || cmd.Run != nil {
|
||||
runnableCmds++
|
||||
} else {
|
||||
groupCmds++
|
||||
}
|
||||
if cmd.Flags() != nil {
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
totalFlags++
|
||||
})
|
||||
}
|
||||
for _, child := range cmd.Commands() {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
|
||||
t.Logf("Command tree stats:")
|
||||
t.Logf(" Total commands: %d", totalCmds)
|
||||
t.Logf(" Runnable commands: %d", runnableCmds)
|
||||
t.Logf(" Group commands: %d", groupCmds)
|
||||
t.Logf(" Total flags: %d", totalFlags)
|
||||
}
|
||||
@@ -42,15 +42,6 @@ type FileIO interface {
|
||||
Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error)
|
||||
}
|
||||
|
||||
// TempDirFileCreator is an optional FileIO capability for atomically creating
|
||||
// a unique directory and an empty named file inside it. The directory pattern
|
||||
// follows os.MkdirTemp semantics: the last '*' is replaced with a random
|
||||
// value. Implementations return a relative file path that can be passed back
|
||||
// to FileIO.
|
||||
type TempDirFileCreator interface {
|
||||
CreateTempDirFile(directoryPattern, fileName string) (string, error)
|
||||
}
|
||||
|
||||
// FileInfo is a minimal subset of os.FileInfo covering actual CLI usage.
|
||||
// os.FileInfo satisfies this interface.
|
||||
type FileInfo interface {
|
||||
|
||||
1
go.mod
1
go.mod
@@ -18,7 +18,6 @@ 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
|
||||
|
||||
2
go.sum
2
go.sum
@@ -131,8 +131,6 @@ 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=
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// StatLocalFile returns metadata for a path in the process filesystem namespace.
|
||||
// It is intended for advisory validation; callers must validate the opened file
|
||||
// again before using its contents.
|
||||
func StatLocalFile(path string) (fs.FileInfo, error) {
|
||||
localPath, err := validate.LocalInputPath(path)
|
||||
if err != nil {
|
||||
return nil, &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
return vfs.Stat(localPath)
|
||||
}
|
||||
|
||||
// OpenLocalFile opens a path in the process filesystem namespace.
|
||||
// Absolute and relative paths are accepted. It is the shared replacement for
|
||||
// direct os.Open/os.ReadFile use in commands that intentionally read local
|
||||
// paths outside the workspace sandbox. Callers inspect the returned descriptor
|
||||
// before reading so validation and use apply to the same opened file.
|
||||
func OpenLocalFile(path string) (fs.File, error) {
|
||||
localPath, err := validate.LocalInputPath(path)
|
||||
if err != nil {
|
||||
return nil, &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
return vfs.Open(localPath)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workDir := filepath.Join(root, "work")
|
||||
if err := os.Mkdir(workDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(root, "input.txt")
|
||||
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
TestChdir(t, workDir)
|
||||
|
||||
for _, input := range []string{path, filepath.Join("..", "input.txt")} {
|
||||
f, err := OpenLocalFile(input)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
|
||||
}
|
||||
got, readErr := io.ReadAll(f)
|
||||
closeErr := f.Close()
|
||||
if readErr != nil || closeErr != nil || string(got) != "content" {
|
||||
t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
|
||||
if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
|
||||
t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
|
||||
info, err := StatLocalFile(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("StatLocalFile() error = %v", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "input.txt")
|
||||
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
previous := vfs.DefaultFS
|
||||
counting := &countingLocalFileFS{FS: previous}
|
||||
vfs.DefaultFS = counting
|
||||
t.Cleanup(func() { vfs.DefaultFS = previous })
|
||||
|
||||
f, err := OpenLocalFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenLocalFile() error = %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counting.openCalls != 1 || counting.statCalls != 0 {
|
||||
t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
|
||||
}
|
||||
}
|
||||
|
||||
type countingLocalFileFS struct {
|
||||
vfs.FS
|
||||
openCalls int
|
||||
statCalls int
|
||||
}
|
||||
|
||||
func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
|
||||
f.openCalls++
|
||||
return f.FS.Open(name)
|
||||
}
|
||||
|
||||
func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
|
||||
f.statCalls++
|
||||
return f.FS.Stat(name)
|
||||
}
|
||||
@@ -17,13 +17,6 @@ func SafeInputPath(path string) (string, error) {
|
||||
return localfileio.SafeInputPath(path)
|
||||
}
|
||||
|
||||
// LocalInputPath validates a local input path without restricting it to the
|
||||
// current working directory. It delegates to localfileio.LocalInputPath so
|
||||
// command validation and shared local-file readers use one policy.
|
||||
func LocalInputPath(path string) (string, error) {
|
||||
return localfileio.LocalInputPath(path)
|
||||
}
|
||||
|
||||
// SafeEnvDirPath validates an environment-provided application directory path.
|
||||
// Delegates to localfileio.SafeEnvDirPath.
|
||||
func SafeEnvDirPath(path, envName string) (string, error) {
|
||||
|
||||
@@ -211,18 +211,6 @@ func TestSafeLocalFlagPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInputPath_AllowsLocalPathsAndRejectsUnsafeCharacters(t *testing.T) {
|
||||
for _, path := range []string{"/tmp/report.pdf", "../report.pdf"} {
|
||||
got, err := LocalInputPath(path)
|
||||
if err != nil || got != path {
|
||||
t.Fatalf("LocalInputPath(%q) = %q, %v; want unchanged path", path, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := LocalInputPath("report\n.pdf"); err == nil {
|
||||
t.Fatal("LocalInputPath() unexpectedly accepted a control character")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a real temp file (absolute path under os.TempDir())
|
||||
f, err := os.CreateTemp("", "upload-test-*.bin")
|
||||
|
||||
@@ -5,14 +5,10 @@ package localfileio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -34,8 +30,6 @@ func init() {
|
||||
// and atomic writes are handled internally.
|
||||
type LocalFileIO struct{}
|
||||
|
||||
var _ fileio.TempDirFileCreator = (*LocalFileIO)(nil)
|
||||
|
||||
// Open opens a local file for reading after validating the path.
|
||||
func (l *LocalFileIO) Open(name string) (fileio.File, error) {
|
||||
safePath, err := SafeInputPath(name)
|
||||
@@ -68,46 +62,6 @@ func (l *LocalFileIO) ResolvePath(path string) (string, error) {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// CreateTempDirFile atomically creates a unique directory in the current
|
||||
// working directory, then creates the requested empty file inside it.
|
||||
func (l *LocalFileIO) CreateTempDirFile(directoryPattern, fileName string) (string, error) {
|
||||
if err := validateTempDirectoryPattern(directoryPattern); err != nil {
|
||||
return "", &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
if err := validateTempFileName(fileName); err != nil {
|
||||
return "", &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
tempDir, err := vfs.MkdirTemp(".", directoryPattern)
|
||||
if err != nil {
|
||||
return "", &fileio.MkdirError{Err: err}
|
||||
}
|
||||
path := filepath.Join(tempDir, fileName)
|
||||
tempFile, err := vfs.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
_ = vfs.RemoveAll(tempDir)
|
||||
return "", &fileio.WriteError{Err: err}
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
_ = vfs.RemoveAll(tempDir)
|
||||
return "", &fileio.WriteError{Err: fmt.Errorf("close temporary file: %w", err)}
|
||||
}
|
||||
return filepath.Join(filepath.Base(tempDir), fileName), nil
|
||||
}
|
||||
|
||||
func validateTempDirectoryPattern(pattern string) error {
|
||||
if strings.TrimSpace(pattern) == "" || strings.ContainsAny(pattern, `/\\`) || strings.Count(pattern, "*") != 1 {
|
||||
return fmt.Errorf("temporary directory pattern must be one non-empty path component containing exactly one '*'")
|
||||
}
|
||||
return charcheck.RejectControlChars(pattern, "temporary directory pattern")
|
||||
}
|
||||
|
||||
func validateTempFileName(fileName string) error {
|
||||
if strings.TrimSpace(fileName) == "" || fileName != filepath.Base(fileName) || strings.ContainsAny(fileName, "/\\\t\r\n") {
|
||||
return fmt.Errorf("temporary file name must be one non-empty path component")
|
||||
}
|
||||
return charcheck.RejectControlChars(fileName, "temporary file name")
|
||||
}
|
||||
|
||||
// Save writes body to path atomically after validating the output path.
|
||||
// Parent directories are created as needed. The body is streamed directly
|
||||
// to a temp file and renamed, avoiding full in-memory buffering.
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -252,81 +250,6 @@ func TestLocalFileIO_ResolvePath_RejectsAbsolute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_CreateTempDirFileIsUniqueUnderConcurrency(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
const count = 32
|
||||
type result struct {
|
||||
path string
|
||||
err error
|
||||
}
|
||||
results := make(chan result, count)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < count; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
path, err := (&LocalFileIO{}).CreateTempDirFile("川西_*_folder", "川西.xml")
|
||||
results <- result{path: path, err: err}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
seen := make(map[string]struct{}, count)
|
||||
for result := range results {
|
||||
if result.err != nil {
|
||||
t.Fatalf("CreateTempDirFile failed: %v", result.err)
|
||||
}
|
||||
directory := filepath.Dir(result.path)
|
||||
if filepath.Base(result.path) != "川西.xml" || filepath.Base(directory) != directory ||
|
||||
!strings.HasPrefix(directory, "川西_") || !strings.HasSuffix(directory, "_folder") {
|
||||
t.Fatalf("CreateTempDirFile path = %q, want 川西_<random>_folder/川西.xml", result.path)
|
||||
}
|
||||
if _, ok := seen[directory]; ok {
|
||||
t.Fatalf("CreateTempDirFile returned duplicate directory %q", directory)
|
||||
}
|
||||
seen[directory] = struct{}{}
|
||||
info, err := os.Stat(result.path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat temporary file %q: %v", result.path, err)
|
||||
}
|
||||
if info.Size() != 0 {
|
||||
t.Fatalf("temporary file %q size = %d, want 0", result.path, info.Size())
|
||||
}
|
||||
}
|
||||
if len(seen) != count {
|
||||
t.Fatalf("unique temporary files = %d, want %d", len(seen), count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_CreateTempDirFileRejectsUnsafeComponents(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
fio := &LocalFileIO{}
|
||||
|
||||
for _, test := range []struct {
|
||||
pattern string
|
||||
fileName string
|
||||
}{
|
||||
{pattern: "../lark-doc-*", fileName: "draft.xml"},
|
||||
{pattern: "lark-doc-*", fileName: "../draft.xml"},
|
||||
{pattern: "lark-doc-*", fileName: `folder\draft.xml`},
|
||||
} {
|
||||
if _, err := fio.CreateTempDirFile(test.pattern, test.fileName); !errors.Is(err, fileio.ErrPathValidation) {
|
||||
t.Errorf("CreateTempDirFile(%q, %q) error = %v, want path validation", test.pattern, test.fileName, err)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read work directory: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("invalid inputs created files: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error message consistency ──
|
||||
|
||||
func TestLocalFileIO_ErrorMessages_ContainCorrectFlagName(t *testing.T) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
@@ -23,32 +22,6 @@ func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
}
|
||||
|
||||
// LocalInputPath validates an input path in the process local filesystem
|
||||
// namespace. It intentionally does not impose cwd containment or canonicalize
|
||||
// the path: absolute paths, parent-relative paths, and symlink traversal retain
|
||||
// their normal OS semantics. Character validation remains mandatory because
|
||||
// paths are user-controlled and may appear in errors or progress output.
|
||||
func LocalInputPath(path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", fmt.Errorf("local input path must not be empty")
|
||||
}
|
||||
if strings.IndexFunc(path, unicode.IsControl) >= 0 {
|
||||
return "", fmt.Errorf("local input path must not contain control characters")
|
||||
}
|
||||
if err := charcheck.RejectControlChars(path, "local input path"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateLocalInputPlatform(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isWindowsNonLocalNamespace(path string) bool {
|
||||
normalized := strings.ReplaceAll(path, "/", `\`)
|
||||
return strings.HasPrefix(normalized, `\\`) || strings.HasPrefix(normalized, `\??\`)
|
||||
}
|
||||
|
||||
// SafeLocalFlagPath validates a flag value as a local file path.
|
||||
// Empty values and http/https URLs are returned unchanged without validation.
|
||||
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
@@ -56,7 +29,7 @@ func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
if _, err := SafeInputPath(value); err != nil {
|
||||
return "", fmt.Errorf("%s: %w", flagName, err)
|
||||
return "", fmt.Errorf("%s: %v", flagName, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package localfileio
|
||||
|
||||
func validateLocalInputPlatform(string) error { return nil }
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validateLocalInputPlatform(path string) error {
|
||||
if isWindowsNonLocalNamespace(path) {
|
||||
return fmt.Errorf("local input path must not use a Windows network or device namespace")
|
||||
}
|
||||
|
||||
cleaned := filepath.Clean(path)
|
||||
volume := filepath.VolumeName(cleaned)
|
||||
remainder := strings.TrimLeft(cleaned[len(volume):], `\/`)
|
||||
for _, component := range strings.FieldsFunc(remainder, func(r rune) bool {
|
||||
return r == '\\' || r == '/'
|
||||
}) {
|
||||
if component == "." || component == ".." {
|
||||
continue
|
||||
}
|
||||
if !filepath.IsLocal(component) {
|
||||
return fmt.Errorf("local input path contains a reserved Windows path component %q", component)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package localfileio
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLocalInputPath_RejectsWindowsNetworkDeviceAndReservedPaths(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`\\server\share\report.pdf`,
|
||||
`//server/share/report.pdf`,
|
||||
`\\.\pipe\upload`,
|
||||
`\\?\C:\Users\agent\report.pdf`,
|
||||
`\\?\UNC\server\share\report.pdf`,
|
||||
`\??\C:\Users\agent\report.pdf`,
|
||||
`C:\Users\agent\NUL.txt`,
|
||||
`CON`,
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
if _, err := LocalInputPath(input); err == nil {
|
||||
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -72,72 +71,6 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInputPath_AllowsLocalNamespaceWithoutRewriting(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"/tmp/report.pdf",
|
||||
"../outside/report.pdf",
|
||||
"./report.pdf",
|
||||
"nested/../report.pdf",
|
||||
`C:\Users\agent\report.pdf`,
|
||||
"报告.pdf",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
got, err := LocalInputPath(input)
|
||||
if err != nil {
|
||||
t.Fatalf("LocalInputPath(%q) error = %v", input, err)
|
||||
}
|
||||
if got != input {
|
||||
t.Fatalf("LocalInputPath(%q) = %q, want path preserved verbatim", input, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsNonLocalNamespace(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`\\server\share\report.pdf`,
|
||||
`//server/share/report.pdf`,
|
||||
`\\.\pipe\upload`,
|
||||
`\\?\C:\Users\agent\report.pdf`,
|
||||
`\\?\UNC\server\share\report.pdf`,
|
||||
`\??\C:\Users\agent\report.pdf`,
|
||||
} {
|
||||
if !isWindowsNonLocalNamespace(input) {
|
||||
t.Errorf("isWindowsNonLocalNamespace(%q) = false, want true", input)
|
||||
}
|
||||
}
|
||||
|
||||
for _, input := range []string{
|
||||
`C:\Users\agent\report.pdf`,
|
||||
`C:/Users/agent/report.pdf`,
|
||||
`..\outside\report.pdf`,
|
||||
`.\report.pdf`,
|
||||
} {
|
||||
if isWindowsNonLocalNamespace(input) {
|
||||
t.Errorf("isWindowsNonLocalNamespace(%q) = true, want false", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInputPath_RejectsEmptyControlAndDangerousUnicode(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"",
|
||||
" ",
|
||||
"file\x00.txt",
|
||||
"file\tname.txt",
|
||||
"file\nname.txt",
|
||||
"file\rname.txt",
|
||||
"file\u202Ename.txt",
|
||||
"file\u200Bname.txt",
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%q", input), func(t *testing.T) {
|
||||
if _, err := LocalInputPath(input); err == nil {
|
||||
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a clean temp directory as CWD
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -12,23 +12,10 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// maxFileListPageSize 是 file_list 分页上限,与后端 paas_storage checkMaxKeys 的 (0, 200] 契约对齐:
|
||||
// page_size > 200 服务端直接返回 ErrInvalidRequest("maxKeys not in range (0, 200]")。CLI 前置校验避免无谓往返。
|
||||
// 注:服务端对 page_size<=0 会兜底为默认值,但 CLI 默认已是 20、显式传 <1 属误用,故与其它 list 命令一致地按 [1, 200] 校验。
|
||||
const maxFileListPageSize = 200
|
||||
|
||||
// validateFileListPageSize 前置校验 --page-size ∈ [1, maxFileListPageSize],与后端 checkMaxKeys 的 (0, 200] 契约对齐。
|
||||
func validateFileListPageSize(n int) error {
|
||||
if n < 1 || n > maxFileListPageSize {
|
||||
return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxFileListPageSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
|
||||
//
|
||||
// GET /apps/{app_id}/storage/file_list。过滤器:--name / --path / --type / --size-gt /
|
||||
// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size(1..200)/--page-token。
|
||||
// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size/--page-token。
|
||||
// file 域不分 dev/online,无 --env。
|
||||
//
|
||||
// pretty 渲染 5 列:file_name / path / size / type / uploaded_at;空结果打 "No files found."。
|
||||
@@ -54,17 +41,13 @@ var AppsFileList = common.Shortcut{
|
||||
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
|
||||
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1..200)"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
// page_size 前置校验:对齐后端 checkMaxKeys 的 (0, 200] 契约,避免 >200 触发服务端 ErrInvalidRequest。
|
||||
if err := validateFileListPageSize(rctx.Int("page-size")); err != nil {
|
||||
return err
|
||||
}
|
||||
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC,回写到 flag 供 buildFileListParams 透传。
|
||||
for _, f := range []string{"uploaded-since", "uploaded-until"} {
|
||||
if strings.TrimSpace(rctx.Str(f)) == "" {
|
||||
|
||||
@@ -82,34 +82,6 @@ func TestAppsFileList_RequiresAppID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileList_PageSizeOutOfRange 验证 --page-size 超出 (0, 200] 契约时前置报 --page-size 校验错误,不发请求。
|
||||
func TestAppsFileList_PageSizeOutOfRange(t *testing.T) {
|
||||
for _, ps := range []string{"0", "201", "500"} {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("page-size=%s: err = %T %v, want *errs.ValidationError", ps, err, err)
|
||||
}
|
||||
if ve.Param != "--page-size" {
|
||||
t.Fatalf("page-size=%s: Param = %q, want --page-size", ps, ve.Param)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileList_PageSizeBoundaryOK 验证边界值 1 与 200 通过校验(dry-run 不报错并把 page_size 下发)。
|
||||
func TestAppsFileList_PageSizeBoundaryOK(t *testing.T) {
|
||||
for _, ps := range []string{"1", "200"} {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("page-size=%s: dry-run err=%v", ps, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤器 + 分页全部进 query(size-gt/lt 走 int,uploaded_since/until 原样)。
|
||||
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -46,7 +47,21 @@ var AppsFileUpload = common.Shortcut{
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return rctx.ValidateLocalFileFlag("file", fileUploadMaxBytes)
|
||||
f := strings.TrimSpace(rctx.Str("file"))
|
||||
if f == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
|
||||
}
|
||||
st, err := rctx.FileIO().Stat(f)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
|
||||
}
|
||||
if st.IsDir() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
|
||||
}
|
||||
if st.Size() > fileUploadMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
@@ -61,9 +76,9 @@ var AppsFileUpload = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
localPath := strings.TrimSpace(rctx.Str("file"))
|
||||
content, err := rctx.ReadLocalFileFlag("file", fileUploadMaxBytes)
|
||||
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
|
||||
}
|
||||
fileName := filepath.Base(localPath)
|
||||
contentType := mimeByExt(fileName)
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -59,17 +58,22 @@ func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_DryRunPreUpload verifies that dry-run validates the local
|
||||
// file and previews the pre-upload request without reading or uploading it.
|
||||
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_upload,body.file_name 取文件 basename。
|
||||
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
|
||||
absolutePath := filepath.Join(t.TempDir(), "logo.png")
|
||||
if err := os.WriteFile(absolutePath, []byte("not-read-by-dry-run"), 0o600); err != nil {
|
||||
// Validate 会 Stat --file(在 DryRun 之前),故 dry-run 也需要真实存在的文件。
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", absolutePath, "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
@@ -83,18 +87,6 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_DryRunRejectsMissingFile(t *testing.T) {
|
||||
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "logo.png")
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", missingAbsolutePath, "--dry-run", "--as", "user"}, factory, stdout)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 三步直传:pre-upload → 客户端 PUT 字节 → callback。
|
||||
func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||
var putBody []byte
|
||||
@@ -157,142 +149,6 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_AcceptsAbsolutePath verifies that file-upload can read an
|
||||
// absolute path outside the current working directory.
|
||||
func TestAppsFileUpload_AcceptsAbsolutePath(t *testing.T) {
|
||||
var putBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
putBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("ETag", `"etag-abs"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Keep the process cwd unchanged so the temporary file is outside it.
|
||||
dir := t.TempDir()
|
||||
absFile := filepath.Join(dir, "report.pdf")
|
||||
if !filepath.IsAbs(absFile) {
|
||||
t.Fatalf("test setup: %q is not absolute", absFile)
|
||||
}
|
||||
if err := os.WriteFile(absFile, []byte("PDFBYTES"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-abs"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"file_name": "report.pdf", "path": "/1858537546760999.pdf", "size_bytes": 8,
|
||||
}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", absFile, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute with absolute path err=%v", err)
|
||||
}
|
||||
if string(putBody) != "PDFBYTES" {
|
||||
t.Fatalf("PUT body = %q, want file bytes", putBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_AcceptsParentRelativePathOutsideCWD(t *testing.T) {
|
||||
var putBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
putBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("ETag", `"etag-parent"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
root := t.TempDir()
|
||||
workDir := filepath.Join(root, "work")
|
||||
if err := os.Mkdir(workDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte("PARENT"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(workDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-parent"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"file_name": "report.pdf", "path": "/parent.pdf", "size_bytes": 6,
|
||||
}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", filepath.Join("..", "report.pdf"), "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute with parent-relative path err=%v", err)
|
||||
}
|
||||
if string(putBody) != "PARENT" {
|
||||
t.Fatalf("PUT body = %q, want PARENT", putBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_RejectsFileAboveLimit(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "too-large.bin")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Truncate(fileUploadMaxBytes + 1); err != nil {
|
||||
_ = f.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err = runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", path, "--as", "user"}, factory, stdout)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Error(), "limit") {
|
||||
t.Fatalf("error = %v, want size limit context", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_RejectsDeviceWithoutReadingIt(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("/dev/zero is unavailable on Windows")
|
||||
}
|
||||
if _, err := os.Stat("/dev/zero"); err != nil {
|
||||
t.Skipf("/dev/zero unavailable: %v", err)
|
||||
}
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "/dev/zero", "--as", "user"}, factory, stdout)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Error(), "regular file") {
|
||||
t.Fatalf("error = %v, want non-regular-file context", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName:空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
|
||||
func TestSanitizeUploadFileName_Cases(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
|
||||
@@ -26,7 +26,7 @@ var BaseFormSubmit = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+form-submit",
|
||||
Description: "Submit a form (fill and submit form data)",
|
||||
Risk: "high-risk-write",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:form:update", "docs:document.media:upload"},
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
@@ -39,7 +39,6 @@ var BaseFormSubmit = common.Shortcut{
|
||||
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
|
||||
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
|
||||
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
|
||||
baseHighRiskYesTip,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFormSubmit(runtime)
|
||||
|
||||
@@ -2056,8 +2056,8 @@ func TestBaseFormSubmitShortcut(t *testing.T) {
|
||||
if s.Service != "base" {
|
||||
t.Fatalf("Service=%q want base", s.Service)
|
||||
}
|
||||
if s.Risk != "high-risk-write" {
|
||||
t.Fatalf("Risk=%q want high-risk-write", s.Risk)
|
||||
if s.Risk != "write" {
|
||||
t.Fatalf("Risk=%q want write", s.Risk)
|
||||
}
|
||||
if !s.HasFormat {
|
||||
t.Fatal("HasFormat should be true")
|
||||
@@ -2357,7 +2357,6 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
"+form-submit",
|
||||
"--share-token", "shr_exec1",
|
||||
"--json", `{"fields":{"Name":"Alice","Rating":5}}`,
|
||||
"--yes",
|
||||
}
|
||||
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -2426,7 +2425,6 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
"--share-token", "shr_exec6",
|
||||
"--base-token", "bas_exec6",
|
||||
"--json", `{"attachments":{"File":["./nonexistent.pdf"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
||||
if err == nil {
|
||||
@@ -2475,7 +2473,6 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
"--share-token", "shr_dedup",
|
||||
"--base-token", "bas_dedup",
|
||||
"--json", `{"attachments":{"FieldA":["./shared.pdf"],"FieldB":["./shared.pdf"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -2487,33 +2484,6 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormSubmitRequiresConfirmation pins the high-risk-write classification:
|
||||
// without --yes the runner's confirmation gate must fire before Execute runs,
|
||||
// returning a typed confirmation_required error and touching no API.
|
||||
func TestFormSubmitRequiresConfirmation(t *testing.T) {
|
||||
if BaseFormSubmit.Risk != "high-risk-write" {
|
||||
t.Fatalf("Risk=%q want high-risk-write", BaseFormSubmit.Risk)
|
||||
}
|
||||
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{
|
||||
"+form-submit",
|
||||
"--share-token", "shr_confirm",
|
||||
"--json", `{"fields":{"Rating":5}}`,
|
||||
}
|
||||
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation_required error without --yes")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeConfirmationRequired {
|
||||
t.Fatalf("subtype=%q want %q", problem.Subtype, errs.SubtypeConfirmationRequired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentsParallel(t *testing.T) {
|
||||
t.Run("single file upload via execute path", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
@@ -2550,7 +2520,6 @@ func TestUploadAttachmentsParallel(t *testing.T) {
|
||||
"--share-token", "shr_para1",
|
||||
"--base-token", "bas_para1",
|
||||
"--json", `{"attachments":{"Doc":["./doc.txt"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -2585,7 +2554,6 @@ func TestUploadAttachmentsParallel(t *testing.T) {
|
||||
"--share-token", "shr_err",
|
||||
"--base-token", "bas_err",
|
||||
"--json", `{"attachments":{"Bad":["./bad.txt"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
||||
if err == nil {
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// ValidateLocalFileFlag validates that a local input path exists, is a regular
|
||||
// file, and does not exceed maxBytes. Absolute and relative paths use
|
||||
// the process filesystem namespace.
|
||||
func (ctx *RuntimeContext) ValidateLocalFileFlag(flagName string, maxBytes int64) error {
|
||||
path, param, err := ctx.localFileFlag(flagName, maxBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := cmdutil.StatLocalFile(path)
|
||||
if err != nil {
|
||||
return localFileReadError(param, path, "inspect", err)
|
||||
}
|
||||
if err := localFileRegularError(param, path, info.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() > maxBytes {
|
||||
return localFileSizeError(param, path, info.Size(), maxBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadLocalFileFlag is the shared replacement for direct os.ReadFile calls in
|
||||
// shortcuts. It accepts absolute and relative paths, enforces a hard size
|
||||
// limit, and returns command-facing typed errors.
|
||||
func (ctx *RuntimeContext) ReadLocalFileFlag(flagName string, maxBytes int64) (data []byte, retErr error) {
|
||||
path, param, err := ctx.localFileFlag(flagName, maxBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := cmdutil.OpenLocalFile(path)
|
||||
if err != nil {
|
||||
return nil, localFileReadError(param, path, "open", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil && retErr == nil {
|
||||
data = nil
|
||||
retErr = errs.NewInternalError(errs.SubtypeFileIO, "cannot close %s %q: %v", param, path, err).WithCause(err)
|
||||
}
|
||||
}()
|
||||
|
||||
openedInfo, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, localFileReadError(param, path, "inspect opened", err)
|
||||
}
|
||||
if err := localFileRegularError(param, path, openedInfo.Mode()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if openedInfo.Size() > maxBytes {
|
||||
return nil, localFileSizeError(param, path, openedInfo.Size(), maxBytes)
|
||||
}
|
||||
|
||||
readLimit := maxBytes + 1
|
||||
if maxBytes == math.MaxInt64 {
|
||||
readLimit = maxBytes
|
||||
}
|
||||
data, err = io.ReadAll(io.LimitReader(f, readLimit))
|
||||
if err != nil {
|
||||
return nil, localFileReadError(param, path, "read", err)
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q grew beyond the %d-byte limit while being read", param, path, maxBytes).
|
||||
WithParam(param)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) localFileFlag(flagName string, maxBytes int64) (path, param string, err error) {
|
||||
name, param, err := localFileFlagNames(flagName)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if ctx == nil || ctx.Cmd == nil {
|
||||
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "cannot read %s: runtime command is unavailable", param)
|
||||
}
|
||||
|
||||
path = strings.TrimSpace(ctx.Str(name))
|
||||
if path == "" {
|
||||
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s is required", param).WithParam(param)
|
||||
}
|
||||
if _, err := validate.LocalInputPath(path); err != nil {
|
||||
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s path: %v", param, err).
|
||||
WithParam(param).
|
||||
WithCause(err)
|
||||
}
|
||||
if maxBytes < 0 {
|
||||
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "invalid read limit configured for %s", param)
|
||||
}
|
||||
return path, param, nil
|
||||
}
|
||||
|
||||
func localFileRegularError(param, path string, mode fs.FileMode) error {
|
||||
if mode.IsRegular() {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q is not a regular file", param, path).
|
||||
WithParam(param)
|
||||
}
|
||||
|
||||
func localFileReadError(param, path, op string, cause error) error {
|
||||
if errors.Is(cause, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s %q: %v", param, path, cause).
|
||||
WithParam(param).
|
||||
WithCause(cause)
|
||||
}
|
||||
if errors.Is(cause, fs.ErrNotExist) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s %q does not exist", param, path).
|
||||
WithParam(param).
|
||||
WithCause(cause)
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot %s %s %q: %v", op, param, path, cause).WithCause(cause)
|
||||
}
|
||||
|
||||
func localFileSizeError(param, path string, size, limit int64) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q is %d bytes; limit is %d bytes", param, path, size, limit).
|
||||
WithParam(param)
|
||||
}
|
||||
|
||||
func localFileFlagNames(flagName string) (name, param string, err error) {
|
||||
name = strings.TrimLeft(strings.TrimSpace(flagName), "-")
|
||||
if name == "" {
|
||||
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "local file flag name must not be empty")
|
||||
}
|
||||
return name, "--" + name, nil
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestReadLocalFileFlag_AcceptsAbsolutePath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "input.txt")
|
||||
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rctx := localFileTestRuntime(t, path)
|
||||
|
||||
if err := rctx.ValidateLocalFileFlag("file", 7); err != nil {
|
||||
t.Fatalf("ValidateLocalFileFlag() error = %v", err)
|
||||
}
|
||||
got, err := rctx.ReadLocalFileFlag("file", 7)
|
||||
if err != nil || string(got) != "content" {
|
||||
t.Fatalf("ReadLocalFileFlag() = %q, %v; want content", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path func(t *testing.T) string
|
||||
max int64
|
||||
}{
|
||||
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
|
||||
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
|
||||
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
|
||||
{name: "too large", path: func(t *testing.T) string {
|
||||
path := filepath.Join(t.TempDir(), "large")
|
||||
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}, max: 5},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := localFileTestRuntime(t, tc.path(t)).ValidateLocalFileFlag("file", tc.max)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path func(t *testing.T) string
|
||||
max int64
|
||||
}{
|
||||
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
|
||||
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
|
||||
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
|
||||
{name: "too large", path: func(t *testing.T) string {
|
||||
path := filepath.Join(t.TempDir(), "large")
|
||||
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}, max: 5},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := localFileTestRuntime(t, tc.path(t)).ReadLocalFileFlag("file", tc.max)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func localFileTestRuntime(t *testing.T, path string) *RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
if err := cmd.Flags().Set("file", path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &RuntimeContext{ctx: context.Background(), Cmd: cmd}
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/doc/internal/docxparse"
|
||||
)
|
||||
|
||||
const (
|
||||
docsScriptParse = "parse"
|
||||
docsScriptMarkdownToXML = "markdown-to-xml"
|
||||
docsScriptCreateTempXML = "create-temp-xml"
|
||||
docsScriptTempDirSuffix = "_*_folder"
|
||||
)
|
||||
|
||||
var DocsScript = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+script",
|
||||
Description: "Create a unique temporary XML file, parse and profile local or online documents, or convert Markdown to LarkOpenCLI XML",
|
||||
Risk: "read",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{},
|
||||
ConditionalScopes: []string{
|
||||
"docx:document:readonly",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{
|
||||
Name: "command",
|
||||
Desc: "local document operation",
|
||||
Required: true,
|
||||
Enum: []string{docsScriptParse, docsScriptMarkdownToXML, docsScriptCreateTempXML},
|
||||
},
|
||||
{
|
||||
Name: "content",
|
||||
Desc: "local content for parse or markdown-to-xml; use @relative-file or - for stdin; mutually exclusive with --doc",
|
||||
Input: []string{common.File, common.Stdin},
|
||||
},
|
||||
{
|
||||
Name: "doc",
|
||||
Desc: "online document URL or token for --command parse; mutually exclusive with --content",
|
||||
},
|
||||
{
|
||||
Name: "output",
|
||||
Desc: "local XML output path for markdown-to-xml; omit to return XML in data.xml",
|
||||
},
|
||||
{
|
||||
Name: "file-name",
|
||||
Desc: "portable base name without .xml; create-temp-xml writes <name>_<random>_folder/<name>.xml",
|
||||
},
|
||||
{
|
||||
Name: "overwrite",
|
||||
Type: "bool",
|
||||
Desc: "overwrite an existing --output file",
|
||||
},
|
||||
},
|
||||
Tips: []string{
|
||||
"create-temp-xml atomically creates <file-name>_<random>_folder/<file-name>.xml in the current directory",
|
||||
"parse accepts local --content or an online --doc URL/token and returns only the text and block profile",
|
||||
"markdown-to-xml converts Markdown to LarkOpenCLI XML",
|
||||
"use --output to save converted XML directly and keep stdout compact",
|
||||
},
|
||||
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"`
|
||||
}
|
||||
|
||||
type docsScriptMarkdownFileResult struct {
|
||||
SavedPath string `json:"saved_path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
type docsScriptTempXMLResult struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func installDocsScriptHelp(cmd *cobra.Command) {
|
||||
installDocsShortcutHelp("+script")(cmd)
|
||||
cmd.Example = ` lark-cli docs +script --command create-temp-xml --file-name "draft"
|
||||
lark-cli docs +script --command parse --content "@draft.xml"
|
||||
lark-cli docs +script --command parse --content "@draft.md"
|
||||
lark-cli docs +script --command parse --doc "https://example.larksuite.com/docx/doxcn..."
|
||||
lark-cli docs +script --command markdown-to-xml --content "@draft.md" --output "draft.xml"`
|
||||
}
|
||||
|
||||
func validateDocsScript(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
content := strings.TrimSpace(runtime.Str("content"))
|
||||
doc := strings.TrimSpace(runtime.Str("doc"))
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
fileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
if runtime.Str("command") == docsScriptCreateTempXML {
|
||||
switch {
|
||||
case content != "":
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--content is not supported with --command create-temp-xml").WithParam("--content")
|
||||
case doc != "":
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--doc is not supported with --command create-temp-xml").WithParam("--doc")
|
||||
case outputPath != "":
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--output is not supported with --command create-temp-xml").WithParam("--output")
|
||||
case runtime.Bool("overwrite"):
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--overwrite is not supported with --command create-temp-xml").WithParam("--overwrite")
|
||||
case fileName == "":
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name is required with --command create-temp-xml").WithParam("--file-name")
|
||||
case runtime.Str("file-name") != fileName:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name must not start or end with whitespace").WithParam("--file-name")
|
||||
default:
|
||||
return validateDocsScriptTempXMLFileName(fileName)
|
||||
}
|
||||
}
|
||||
if fileName != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name is only supported with --command create-temp-xml").WithParam("--file-name")
|
||||
}
|
||||
if content == "" && doc == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --content or --doc is required").WithParams(
|
||||
errs.InvalidParam{Name: "--content", Reason: "provide local document content"},
|
||||
errs.InvalidParam{Name: "--doc", Reason: "provide an online document URL or token"},
|
||||
)
|
||||
}
|
||||
if content != "" && doc != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content and --doc are mutually exclusive").WithParams(
|
||||
errs.InvalidParam{Name: "--content", Reason: "mutually exclusive with --doc"},
|
||||
errs.InvalidParam{Name: "--doc", Reason: "mutually exclusive with --content"},
|
||||
)
|
||||
}
|
||||
if doc != "" {
|
||||
if runtime.Str("command") != docsScriptParse {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--doc is only supported with --command parse").WithParam("--doc")
|
||||
}
|
||||
if _, err := parseDocumentRef(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.EnsureScopes([]string{"docx:document:readonly"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if outputPath == "" {
|
||||
if runtime.Bool("overwrite") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--overwrite requires --output").WithParam("--overwrite")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if runtime.Str("command") != docsScriptMarkdownToXML {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--output is only supported with --command markdown-to-xml").WithParam("--output")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).
|
||||
WithParam("--output").
|
||||
WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunDocsScript(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if runtime.Str("command") == docsScriptCreateTempXML {
|
||||
fileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Create a random directory and an empty named XML file inside it; no API call is made").
|
||||
Set("command", docsScriptCreateTempXML).
|
||||
Set("directory_pattern", docsScriptTempDirectoryPattern(fileName)).
|
||||
Set("file_name", fileName).
|
||||
Set("xml_file_name", docsScriptXMLFileName(fileName)).
|
||||
Set("creates_file", false).
|
||||
Set("network", false)
|
||||
}
|
||||
if doc := strings.TrimSpace(runtime.Str("doc")); doc != "" {
|
||||
ref, _ := parseDocumentRef(doc)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/docs_ai/v1/documents/:document_id/fetch").
|
||||
Desc("OpenAPI: fetch document for parsing and profiling").
|
||||
Body(docsScriptFetchBody(runtime)).
|
||||
Set("command", runtime.Str("command")).
|
||||
Set("document_id", ref.Token).
|
||||
Set("network", true)
|
||||
}
|
||||
dry := 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)
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
dry.Set("output", outputPath).Set("overwrite", runtime.Bool("overwrite"))
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
func executeDocsScript(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
command := runtime.Str("command")
|
||||
content := runtime.Str("content")
|
||||
switch command {
|
||||
case docsScriptCreateTempXML:
|
||||
return createDocsScriptTempXML(runtime)
|
||||
case docsScriptParse:
|
||||
inputParam := "--content"
|
||||
inputLabel := "--content"
|
||||
if strings.TrimSpace(runtime.Str("doc")) != "" {
|
||||
var err error
|
||||
content, err = fetchDocsScriptContent(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inputParam = "--doc"
|
||||
inputLabel = "fetched document content"
|
||||
}
|
||||
profile, err := docxparse.ParseAuto(content)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"could not parse %s as LarkOpenCLI XML or Markdown: %s", inputLabel, err).
|
||||
WithParam(inputParam).
|
||||
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)
|
||||
}
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
return saveDocsScriptXML(runtime, outputPath, xml)
|
||||
}
|
||||
runtime.OutFormatRaw(docsScriptMarkdownResult{XML: xml}, nil, nil)
|
||||
return nil
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported --command %q", command).
|
||||
WithParam("--command")
|
||||
}
|
||||
}
|
||||
|
||||
func createDocsScriptTempXML(runtime *common.RuntimeContext) error {
|
||||
creator, ok := runtime.FileIO().(fileio.TempDirFileCreator)
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"the active file I/O provider does not support temporary file creation").
|
||||
WithHint("run this command with the local file I/O provider")
|
||||
}
|
||||
fileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
path, err := creator.CreateTempDirFile(docsScriptTempDirectoryPattern(fileName), docsScriptXMLFileName(fileName))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(path); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"resolve temporary XML path %s: %s", path, err).
|
||||
WithCause(err)
|
||||
}
|
||||
runtime.Out(docsScriptTempXMLResult{
|
||||
Path: path,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocsScriptTempXMLFileName(fileName string) error {
|
||||
if fileName != filepath.Base(fileName) || strings.ContainsAny(fileName, "<>:\"/\\|?*\t\r\n") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name must be a portable file name without path separators or reserved characters").WithParam("--file-name")
|
||||
}
|
||||
if err := charcheck.RejectControlChars(fileName, "--file-name"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--file-name").
|
||||
WithCause(err)
|
||||
}
|
||||
if strings.HasSuffix(fileName, ".") || strings.HasSuffix(fileName, " ") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name must not end with a dot or space").WithParam("--file-name")
|
||||
}
|
||||
if strings.EqualFold(filepath.Ext(fileName), ".xml") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name must omit the .xml extension").WithParam("--file-name")
|
||||
}
|
||||
base := strings.ToUpper(strings.SplitN(fileName, ".", 2)[0])
|
||||
if isWindowsReservedFileName(base) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name uses a Windows-reserved device name").WithParam("--file-name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func docsScriptTempDirectoryPattern(fileName string) string {
|
||||
return fileName + docsScriptTempDirSuffix
|
||||
}
|
||||
|
||||
func docsScriptXMLFileName(fileName string) string {
|
||||
return fileName + ".xml"
|
||||
}
|
||||
|
||||
func isWindowsReservedFileName(base string) bool {
|
||||
switch base {
|
||||
case "CON", "PRN", "AUX", "NUL":
|
||||
return true
|
||||
}
|
||||
if len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) {
|
||||
return base[3] >= '1' && base[3] <= '9'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func docsScriptFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": "xml",
|
||||
"extra_param": docsFetchExtraParam,
|
||||
"export_option": map[string]interface{}{
|
||||
"export_block_id": false,
|
||||
"export_style_attrs": false,
|
||||
"export_cite_extra_data": false,
|
||||
},
|
||||
}
|
||||
if lang := resolveFetchLang(runtime); lang != "" {
|
||||
body["lang"] = lang
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func fetchDocsScriptContent(runtime *common.RuntimeContext) (string, error) {
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
|
||||
data, err := doDocAPI(runtime, "POST", apiPath, docsScriptFetchBody(runtime))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
document, ok := data["document"].(map[string]interface{})
|
||||
if !ok || document == nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"document fetch response for --doc is missing document")
|
||||
}
|
||||
content, ok := document["content"].(string)
|
||||
if !ok {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"document fetch response for --doc is missing document.content")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func saveDocsScriptXML(runtime *common.RuntimeContext, outputPath, xml string) error {
|
||||
if !runtime.Bool("overwrite") {
|
||||
if _, err := runtime.FileIO().Stat(outputPath); err == nil {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"output file already exists: %s (use --overwrite to replace)", outputPath).
|
||||
WithParam("--output")
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).
|
||||
WithParam("--output").
|
||||
WithCause(err)
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"cannot access output path %s: %s", outputPath, err).
|
||||
WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(xml)),
|
||||
}, strings.NewReader(xml))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
savedPath, err := runtime.ResolveSavePath(outputPath)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"resolve saved XML path %s: %s", outputPath, err).
|
||||
WithCause(err)
|
||||
}
|
||||
runtime.Out(docsScriptMarkdownFileResult{
|
||||
SavedPath: savedPath,
|
||||
SizeBytes: result.Size(),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
@@ -1,660 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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 TestDocsScriptParsesOnlineDocumentFromToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-online-token"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcnScriptToken/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnScriptToken",
|
||||
"content": `<title>在线文档</title><p>Hello world</p>`,
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--doc", "doxcnScriptToken",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script with token: %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 != 2 {
|
||||
t.Fatalf("profile = %+v, want 2 blocks", envelope.Data.Profile)
|
||||
}
|
||||
if got := blockCount(envelope.Data.Profile.Blocks, "title"); got != 1 {
|
||||
t.Fatalf("title count = %d, want 1", got)
|
||||
}
|
||||
if got := blockCount(envelope.Data.Profile.Blocks, "p"); got != 1 {
|
||||
t.Fatalf("p count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptParsesOnlineDocumentFromURL(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-online-url"))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/wikcnScriptURL/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnResolvedScriptURL",
|
||||
"content": `<p>从 Wiki URL 读取</p>`,
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--doc", "https://example.larksuite.com/wiki/wikcnScriptURL",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script with URL: %v", err)
|
||||
}
|
||||
if stub.CapturedBody == nil {
|
||||
t.Fatal("online parse did not call the document fetch API")
|
||||
}
|
||||
|
||||
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 != 1 || blockCount(envelope.Data.Profile.Blocks, "p") != 1 {
|
||||
t.Fatalf("profile = %+v, want one paragraph", envelope.Data.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptRejectsContentAndDocTogether(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-input-conflict"))
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", `<p>local</p>`,
|
||||
"--doc", "doxcnScriptConflict",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--content", "--doc")
|
||||
}
|
||||
|
||||
func TestDocsScriptRejectsDocForMarkdownConversion(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-doc-convert"))
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--doc", "doxcnScriptConvert",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--doc")
|
||||
}
|
||||
|
||||
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 TestDocsScriptConvertsMarkdownToOutputFile(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
withDocsWorkingDir(t, workDir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output"))
|
||||
wantXML := `<h1>标题</h1><ul><li>item</li></ul>`
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "# 标题\n\n- item",
|
||||
"--output", "draft.xml",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script: %v", err)
|
||||
}
|
||||
gotXML, err := os.ReadFile("draft.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("read output XML: %v", err)
|
||||
}
|
||||
if string(gotXML) != wantXML {
|
||||
t.Fatalf("output XML = %q, want %q", gotXML, wantXML)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
SavedPath string `json:"saved_path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
XML json.RawMessage `json:"xml"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if envelope.Data.SavedPath != filepath.Join(workDir, "draft.xml") {
|
||||
t.Fatalf("saved_path = %q, want %q", envelope.Data.SavedPath, filepath.Join(workDir, "draft.xml"))
|
||||
}
|
||||
if envelope.Data.SizeBytes != int64(len(wantXML)) {
|
||||
t.Fatalf("size_bytes = %d, want %d", envelope.Data.SizeBytes, len(wantXML))
|
||||
}
|
||||
if envelope.Data.XML != nil {
|
||||
t.Fatalf("data.xml should be omitted when --output is used: %s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptCreatesUniqueTempXMLFiles(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
withDocsWorkingDir(t, workDir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml"))
|
||||
|
||||
create := func() docsScriptTempXMLResult {
|
||||
t.Helper()
|
||||
stdout.Reset()
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptCreateTempXML,
|
||||
"--file-name", "川西",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script: %v", err)
|
||||
}
|
||||
var envelope struct {
|
||||
Data docsScriptTempXMLResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
first := create()
|
||||
second := create()
|
||||
if first.Path == second.Path {
|
||||
t.Fatalf("temporary paths are identical: %q", first.Path)
|
||||
}
|
||||
for _, got := range []docsScriptTempXMLResult{first, second} {
|
||||
directory := filepath.Dir(got.Path)
|
||||
if filepath.Base(got.Path) != "川西.xml" || filepath.Base(directory) != directory ||
|
||||
!strings.HasPrefix(directory, "川西_") || !strings.HasSuffix(directory, "_folder") {
|
||||
t.Fatalf("path = %q, want 川西_<random>_folder/川西.xml", got.Path)
|
||||
}
|
||||
info, err := os.Stat(got.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat temporary XML %q: %v", got.Path, err)
|
||||
}
|
||||
if info.Size() != 0 {
|
||||
t.Fatalf("temporary XML %q size = %d, want 0", got.Path, info.Size())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptCreateTempXMLRejectsOtherFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
param string
|
||||
}{
|
||||
{name: "content", args: []string{"--content", "<p>text</p>"}, param: "--content"},
|
||||
{name: "doc", args: []string{"--doc", "doxcnScriptTemp"}, param: "--doc"},
|
||||
{name: "output", args: []string{"--output", "draft.xml"}, param: "--output"},
|
||||
{name: "overwrite", args: []string{"--overwrite"}, param: "--overwrite"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml-flags"))
|
||||
args := []string{"+script", "--command", docsScriptCreateTempXML, "--file-name", "川西", "--as", "bot"}
|
||||
args = append(args, test.args...)
|
||||
err := mountAndRunDocs(t, DocsScript, args, f, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected %s validation error", test.param)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
||||
!errors.As(err, &validationErr) || validationErr.Param != test.param {
|
||||
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptCreateTempXMLValidatesFileName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileName string
|
||||
}{
|
||||
{name: "missing"},
|
||||
{name: "path", fileName: "folder/川西"},
|
||||
{name: "windows path", fileName: `folder\川西`},
|
||||
{name: "reserved character", fileName: "川西:一"},
|
||||
{name: "xml extension included", fileName: "川西.xml"},
|
||||
{name: "windows device", fileName: "CON"},
|
||||
{name: "surrounding whitespace", fileName: " 川西"},
|
||||
{name: "dangerous unicode", fileName: "川\u200b西"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml-file-name"))
|
||||
args := []string{"+script", "--command", docsScriptCreateTempXML, "--as", "bot"}
|
||||
if test.fileName != "" {
|
||||
args = append(args, "--file-name", test.fileName)
|
||||
}
|
||||
err := mountAndRunDocs(t, DocsScript, args, f, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected --file-name validation error for %q", test.fileName)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
||||
!errors.As(err, &validationErr) || validationErr.Param != "--file-name" {
|
||||
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptOutputRequiresExplicitOverwrite(t *testing.T) {
|
||||
withDocsWorkingDir(t, t.TempDir())
|
||||
if err := os.WriteFile("draft.xml", []byte("old"), 0o600); err != nil {
|
||||
t.Fatalf("write existing output: %v", err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-overwrite"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "# new",
|
||||
"--output", "draft.xml",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected existing output error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition ||
|
||||
!errors.As(err, &validationErr) || validationErr.Param != "--output" {
|
||||
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
||||
}
|
||||
got, readErr := os.ReadFile("draft.xml")
|
||||
if readErr != nil || string(got) != "old" {
|
||||
t.Fatalf("existing output changed: content=%q err=%v", got, readErr)
|
||||
}
|
||||
|
||||
err = mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "# new",
|
||||
"--output", "draft.xml",
|
||||
"--overwrite",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script with --overwrite: %v", err)
|
||||
}
|
||||
got, readErr = os.ReadFile("draft.xml")
|
||||
if readErr != nil || string(got) != "<h1>new</h1>" {
|
||||
t.Fatalf("overwritten output = %q, err=%v", got, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptRejectsOutputForParse(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output-parse"))
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", `<p>text</p>`,
|
||||
"--output", "draft.xml",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected --output validation error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
||||
!errors.As(err, &validationErr) || validationErr.Param != "--output" {
|
||||
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptRejectsUnsafeOutputPath(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output-path"))
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "# title",
|
||||
"--output", filepath.Join(t.TempDir(), "draft.xml"),
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe output path error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
||||
!errors.As(err, &validationErr) || validationErr.Param != "--output" {
|
||||
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestDocsScriptCreateTempXMLDryRunDoesNotWrite(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
withDocsWorkingDir(t, workDir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml-dry-run"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptCreateTempXML,
|
||||
"--file-name", "川西",
|
||||
"--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"`
|
||||
DirectoryPattern string `json:"directory_pattern"`
|
||||
FileName string `json:"file_name"`
|
||||
XMLFileName string `json:"xml_file_name"`
|
||||
CreatesFile bool `json:"creates_file"`
|
||||
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 != docsScriptCreateTempXML ||
|
||||
got.DirectoryPattern != "川西_*_folder" || got.FileName != "川西" || got.XMLFileName != "川西.xml" ||
|
||||
got.CreatesFile || got.Network {
|
||||
t.Fatalf("dry-run output = %+v", got)
|
||||
}
|
||||
entries, err := os.ReadDir(workDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read work directory: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("dry-run created files: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptOnlineDryRunShowsFetchAPICall(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-online-dry-run"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--doc", "https://example.larksuite.com/docx/doxcnScriptDryRun",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute online docs +script dry-run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Command string `json:"command"`
|
||||
DocumentID string `json:"document_id"`
|
||||
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) != 1 || got.API[0].Method != "POST" ||
|
||||
got.API[0].URL != "/open-apis/docs_ai/v1/documents/doxcnScriptDryRun/fetch" {
|
||||
t.Fatalf("dry-run API = %+v", got.API)
|
||||
}
|
||||
if got.API[0].Body["format"] != "xml" {
|
||||
t.Fatalf("dry-run body = %+v, want XML fetch", got.API[0].Body)
|
||||
}
|
||||
if got.Command != docsScriptParse || got.DocumentID != "doxcnScriptDryRun" || !got.Network {
|
||||
t.Fatalf("dry-run output = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptOutputDryRunDoesNotWrite(t *testing.T) {
|
||||
withDocsWorkingDir(t, t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output-dry-run"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "# title",
|
||||
"--output", "draft.xml",
|
||||
"--overwrite",
|
||||
"--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"`
|
||||
Output string `json:"output"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
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 != docsScriptMarkdownToXML || got.Network || got.Output != "draft.xml" || !got.Overwrite {
|
||||
t.Fatalf("dry-run output = %+v", got)
|
||||
}
|
||||
if _, err := os.Stat("draft.xml"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("dry-run created output file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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{`--command create-temp-xml --file-name "draft"`, `--content "@draft.xml"`, `--content "@draft.md"`, `--output "draft.xml"`} {
|
||||
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
|
||||
}
|
||||
@@ -1,743 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
// Markdown conversion is scoped to the docs +script business domain.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
extast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
gmutil "github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
var markdownParser parser.Parser
|
||||
|
||||
func init() {
|
||||
markdown := goldmark.New(
|
||||
goldmark.WithExtensions(
|
||||
extension.GFM,
|
||||
extension.DefinitionList,
|
||||
&mathExtension{},
|
||||
&underscoreHTMLExtension{},
|
||||
),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithBlockParsers(gmutil.Prioritized(&containerBlockParser{}, 90)),
|
||||
),
|
||||
)
|
||||
markdownParser = markdown.Parser()
|
||||
}
|
||||
|
||||
func parseMarkdown(source string) ([]*Node, error) {
|
||||
if err := validateSource(source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source = strings.TrimPrefix(source, "\uFEFF")
|
||||
source = normalizeListIndent(source)
|
||||
source = preprocessCJKAdjacentMarkup(source)
|
||||
data := []byte(source)
|
||||
document := markdownParser.Parse(text.NewReader(data))
|
||||
return renderBlockChildren(document, data)
|
||||
}
|
||||
|
||||
func renderBlockChildren(parent gast.Node, source []byte) ([]*Node, error) {
|
||||
var out []*Node
|
||||
for child := parent.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
nodes, err := renderBlockNode(child, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, nodes...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func renderBlockNode(node gast.Node, source []byte) ([]*Node, error) {
|
||||
switch node.Kind() {
|
||||
case gast.KindParagraph, gast.KindTextBlock:
|
||||
children, err := renderInlineChildren(node, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wrapParagraphChildren(children), nil
|
||||
case gast.KindHeading:
|
||||
heading := newElement(headingTag(node.(*gast.Heading).Level), nil)
|
||||
children, err := renderInlineChildren(node, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
heading.addChild(child)
|
||||
}
|
||||
return []*Node{heading}, nil
|
||||
case gast.KindBlockquote:
|
||||
return renderContainer("blockquote", nil, node, source)
|
||||
case gast.KindList:
|
||||
return renderList(node.(*gast.List), source)
|
||||
case gast.KindFencedCodeBlock:
|
||||
block := node.(*gast.FencedCodeBlock)
|
||||
language := string(block.Language(source))
|
||||
content := trimOneTrailingNewline(string(node.Lines().Value(source)))
|
||||
lowerLanguage := strings.ToLower(language)
|
||||
if content != "" && (lowerLanguage == "mermaid" || lowerLanguage == "plantuml" || lowerLanguage == "svg") {
|
||||
whiteboard := newElement("whiteboard", map[string]string{"type": lowerLanguage})
|
||||
appendRawTextWithBreaks(whiteboard, content)
|
||||
return []*Node{whiteboard}, nil
|
||||
}
|
||||
attrs := map[string]string(nil)
|
||||
if language != "" {
|
||||
attrs = map[string]string{"lang": language}
|
||||
}
|
||||
pre := newElement("pre", attrs)
|
||||
code := newElement("code", nil)
|
||||
appendRawTextWithBreaks(code, content)
|
||||
pre.addChild(code)
|
||||
return []*Node{pre}, nil
|
||||
case gast.KindCodeBlock:
|
||||
pre := newElement("pre", nil)
|
||||
code := newElement("code", nil)
|
||||
appendRawTextWithBreaks(code, trimOneTrailingNewline(string(node.Lines().Value(source))))
|
||||
pre.addChild(code)
|
||||
return []*Node{pre}, nil
|
||||
case gast.KindThematicBreak:
|
||||
return []*Node{newElement("hr", nil)}, nil
|
||||
case gast.KindHTMLBlock:
|
||||
nodes, err := parseMarkdownHTMLBlock(string(node.Lines().Value(source)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stripMarkdownEscapesInNodes(nodes, false, false)
|
||||
return nodes, nil
|
||||
case kindContainerBlock:
|
||||
container := node.(*containerBlock)
|
||||
return renderContainer(container.spec.tag, container.attrs, node, source)
|
||||
}
|
||||
|
||||
switch node.Kind() {
|
||||
case extast.KindTable:
|
||||
return renderTable(node, source)
|
||||
case extast.KindDefinitionList:
|
||||
return renderDefinitionList(node, source)
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(extractMarkdownText(node, source))
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
paragraph := newElement("p", nil)
|
||||
paragraph.addChild(newText(value))
|
||||
return []*Node{paragraph}, nil
|
||||
}
|
||||
|
||||
// parseMarkdownHTMLBlock handles the source-bearing LarkOpenCLI blocks whose
|
||||
// Markdown bodies are literal text, then delegates every other XML fragment to
|
||||
// the strict XML parser. Escaping literal code is part of Markdown conversion.
|
||||
func parseMarkdownHTMLBlock(fragment string) ([]*Node, error) {
|
||||
trimmed := strings.TrimSpace(fragment)
|
||||
for _, tag := range []string{"code", "whiteboard"} {
|
||||
closing := "</" + tag + ">"
|
||||
if !strings.HasPrefix(trimmed, "<"+tag) || !strings.HasSuffix(trimmed, closing) {
|
||||
continue
|
||||
}
|
||||
token, contentStart, state := scanXMLToken(trimmed, 0)
|
||||
if state != tokenOK || token.closing || token.selfClosing || token.name != tag {
|
||||
return nil, fmt.Errorf("invalid Markdown <%s> block", tag)
|
||||
}
|
||||
contentEnd := len(trimmed) - len(closing)
|
||||
if contentStart > contentEnd {
|
||||
return nil, fmt.Errorf("invalid Markdown <%s> block", tag)
|
||||
}
|
||||
attrs := normalizeAttributes(tag, tag, token.attrs)
|
||||
block := newElement(tag, attrs)
|
||||
appendRawTextWithBreaks(block, strings.Trim(trimmed[contentStart:contentEnd], "\r\n"))
|
||||
return []*Node{block}, nil
|
||||
}
|
||||
return parseXML(fragment)
|
||||
}
|
||||
|
||||
func renderContainer(tag string, attrs map[string]string, node gast.Node, source []byte) ([]*Node, error) {
|
||||
attrs = normalizeAttributes(tag, tag, attrs)
|
||||
container := newElement(tag, attrs)
|
||||
children, err := renderBlockChildren(node, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
container.addChild(child)
|
||||
}
|
||||
return []*Node{container}, nil
|
||||
}
|
||||
|
||||
func renderList(list *gast.List, source []byte) ([]*Node, error) {
|
||||
if isTaskList(list) {
|
||||
return renderTaskList(list, source)
|
||||
}
|
||||
tag := "ul"
|
||||
if list.IsOrdered() {
|
||||
tag = "ol"
|
||||
}
|
||||
listNode := newElement(tag, nil)
|
||||
for child := list.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if child.Kind() != gast.KindListItem {
|
||||
continue
|
||||
}
|
||||
item, err := renderListItem(child.(*gast.ListItem), list.IsTight, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listNode.addChild(item)
|
||||
}
|
||||
return []*Node{listNode}, nil
|
||||
}
|
||||
|
||||
func isTaskList(list *gast.List) bool {
|
||||
first := list.FirstChild()
|
||||
if first == nil || first.Kind() != gast.KindListItem {
|
||||
return false
|
||||
}
|
||||
return findTaskCheckbox(first.(*gast.ListItem)) != nil
|
||||
}
|
||||
|
||||
func findTaskCheckbox(item *gast.ListItem) *extast.TaskCheckBox {
|
||||
for child := item.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if child.Kind() != gast.KindTextBlock && child.Kind() != gast.KindParagraph {
|
||||
continue
|
||||
}
|
||||
if first := child.FirstChild(); first != nil && first.Kind() == extast.KindTaskCheckBox {
|
||||
return first.(*extast.TaskCheckBox)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderTaskList(list *gast.List, source []byte) ([]*Node, error) {
|
||||
var out []*Node
|
||||
for child := list.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if child.Kind() != gast.KindListItem {
|
||||
continue
|
||||
}
|
||||
item := child.(*gast.ListItem)
|
||||
checkboxAST := findTaskCheckbox(item)
|
||||
if checkboxAST == nil {
|
||||
li, err := renderListItem(item, list.IsTight, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ul := newElement("ul", nil)
|
||||
ul.addChild(li)
|
||||
out = append(out, ul)
|
||||
continue
|
||||
}
|
||||
done := "false"
|
||||
if checkboxAST.IsChecked {
|
||||
done = "true"
|
||||
}
|
||||
checkbox := newElement("checkbox", map[string]string{"done": done})
|
||||
for block := item.FirstChild(); block != nil; block = block.NextSibling() {
|
||||
if block.Kind() == gast.KindTextBlock || block.Kind() == gast.KindParagraph {
|
||||
fragment, err := renderInlineFragment(block, source, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := parseMarkdownInlineFragment(fragment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
checkbox.addChild(node)
|
||||
}
|
||||
continue
|
||||
}
|
||||
nodes, err := renderBlockNode(block, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
checkbox.addChild(node)
|
||||
}
|
||||
}
|
||||
out = append(out, checkbox)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func renderListItem(item *gast.ListItem, tight bool, source []byte) (*Node, error) {
|
||||
li := newElement("li", nil)
|
||||
children, err := renderBlockChildren(item, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
if child.tag == "p" && (tight || paragraphOnlyInline(child)) {
|
||||
for _, grandchild := range child.children {
|
||||
li.addChild(grandchild)
|
||||
}
|
||||
continue
|
||||
}
|
||||
li.addChild(child)
|
||||
}
|
||||
return li, nil
|
||||
}
|
||||
|
||||
func renderInlineChildren(node gast.Node, source []byte) ([]*Node, error) {
|
||||
fragment, err := renderInlineFragment(node, source, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := parseMarkdownInlineFragment(fragment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stripMarkdownEscapesInNodes(nodes, false, false)
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// parseMarkdownInlineFragment wraps an inline fragment in a space-preserving
|
||||
// parent while parsing so XML normalization keeps semantic spaces between
|
||||
// adjacent inline elements. The wrapper is removed from the returned nodes.
|
||||
func parseMarkdownInlineFragment(fragment string) ([]*Node, error) {
|
||||
nodes, err := parseXML("<p>" + fragment + "</p>")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(nodes) != 1 || nodes[0].typ != nodeElement || nodes[0].tag != "p" {
|
||||
return nil, fmt.Errorf("invalid Markdown inline fragment")
|
||||
}
|
||||
children := nodes[0].children
|
||||
for _, child := range children {
|
||||
child.parent = nil
|
||||
}
|
||||
return children, nil
|
||||
}
|
||||
|
||||
func renderInlineFragment(parent gast.Node, source []byte, skipCheckbox bool) (string, error) {
|
||||
var out strings.Builder
|
||||
for child := parent.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if skipCheckbox && child.Kind() == extast.KindTaskCheckBox {
|
||||
continue
|
||||
}
|
||||
fragment, err := renderInlineNode(child, source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out.WriteString(fragment)
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
func renderInlineNode(node gast.Node, source []byte) (string, error) {
|
||||
switch node.Kind() {
|
||||
case gast.KindText:
|
||||
textNode := node.(*gast.Text)
|
||||
value := escapeXMLText(stripBackslashEscapes(string(textNode.Value(source))))
|
||||
switch {
|
||||
case textNode.HardLineBreak():
|
||||
value += "<br/>"
|
||||
case textNode.SoftLineBreak():
|
||||
value += " "
|
||||
}
|
||||
return value, nil
|
||||
case gast.KindString:
|
||||
return escapeXMLText(string(node.(*gast.String).Value)), nil
|
||||
case gast.KindEmphasis:
|
||||
tag := "em"
|
||||
if node.(*gast.Emphasis).Level >= 2 {
|
||||
tag = "b"
|
||||
}
|
||||
return renderInlineContainer(node, tag, nil, source)
|
||||
case gast.KindCodeSpan:
|
||||
return elementXML("code", nil, escapeXMLText(collectMarkdownChildText(node, source))), nil
|
||||
case gast.KindLink:
|
||||
link := node.(*gast.Link)
|
||||
attrs := map[string]string{"href": string(link.Destination)}
|
||||
if len(link.Title) > 0 {
|
||||
attrs["title"] = string(link.Title)
|
||||
}
|
||||
children, err := renderInlineFragment(node, source, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if children == "" {
|
||||
children = escapeXMLText(string(link.Destination))
|
||||
}
|
||||
return elementXML("a", attrs, children), nil
|
||||
case gast.KindImage:
|
||||
image := node.(*gast.Image)
|
||||
destination := string(image.Destination)
|
||||
attrs := map[string]string{}
|
||||
if strings.HasPrefix(destination, "http://") || strings.HasPrefix(destination, "https://") {
|
||||
attrs["href"] = destination
|
||||
} else {
|
||||
attrs["src"] = destination
|
||||
}
|
||||
if len(image.Title) > 0 {
|
||||
attrs["title"] = string(image.Title)
|
||||
}
|
||||
return elementXML("img", attrs, ""), nil
|
||||
case gast.KindRawHTML:
|
||||
return string(node.(*gast.RawHTML).Segments.Value(source)), nil
|
||||
case gast.KindAutoLink:
|
||||
link := node.(*gast.AutoLink)
|
||||
return elementXML("a", map[string]string{"href": string(link.URL(source))}, escapeXMLText(string(link.Label(source)))), nil
|
||||
}
|
||||
|
||||
switch node.Kind() {
|
||||
case extast.KindStrikethrough:
|
||||
return renderInlineContainer(node, "del", nil, source)
|
||||
case kindMathInline:
|
||||
return elementXML("latex", nil, escapeXMLText(stripLatexMarkdownEscapes(string(node.(*mathInline).content)))), nil
|
||||
case kindMathBlock:
|
||||
return elementXML("latex", nil, escapeXMLText(stripLatexMarkdownEscapes(string(node.(*mathBlock).content)))), nil
|
||||
case extast.KindTaskCheckBox:
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if node.Type() == gast.TypeBlock {
|
||||
return escapeXMLText(strings.TrimSpace(extractMarkdownText(node, source))), nil
|
||||
}
|
||||
return escapeXMLText(extractMarkdownText(node, source)), nil
|
||||
}
|
||||
|
||||
func renderInlineContainer(node gast.Node, tag string, attrs map[string]string, source []byte) (string, error) {
|
||||
children, err := renderInlineFragment(node, source, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return elementXML(tag, attrs, children), nil
|
||||
}
|
||||
|
||||
func elementXML(tag string, attrs map[string]string, inner string) string {
|
||||
node := newElement(tag, attrs)
|
||||
rendered := renderNodes([]*Node{node})
|
||||
if inner == "" {
|
||||
return rendered
|
||||
}
|
||||
close := "</" + tag + ">"
|
||||
if strings.HasSuffix(rendered, close) {
|
||||
return strings.TrimSuffix(rendered, close) + inner + close
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
func wrapParagraphChildren(children []*Node) []*Node {
|
||||
var out []*Node
|
||||
var inline []*Node
|
||||
flush := func() {
|
||||
if len(inline) == 0 {
|
||||
return
|
||||
}
|
||||
paragraph := newElement("p", nil)
|
||||
for _, child := range inline {
|
||||
paragraph.addChild(child)
|
||||
}
|
||||
out = append(out, paragraph)
|
||||
inline = nil
|
||||
}
|
||||
for _, child := range children {
|
||||
if child != nil && child.typ == nodeElement && layoutOf(child.tag) == layoutBlock {
|
||||
flush()
|
||||
out = append(out, child)
|
||||
continue
|
||||
}
|
||||
inline = append(inline, child)
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
func paragraphOnlyInline(node *Node) bool {
|
||||
if node == nil || node.typ != nodeElement || node.tag != "p" {
|
||||
return false
|
||||
}
|
||||
for _, child := range node.children {
|
||||
if child.typ == nodeElement && layoutOf(child.tag) == layoutBlock {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func renderTable(node gast.Node, source []byte) ([]*Node, error) {
|
||||
table := newElement("table", nil)
|
||||
var body *Node
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
switch child.Kind() {
|
||||
case extast.KindTableHeader:
|
||||
head := newElement("thead", nil)
|
||||
row, err := renderTableRow(child, true, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
head.addChild(row)
|
||||
table.addChild(head)
|
||||
case extast.KindTableRow:
|
||||
if body == nil {
|
||||
body = newElement("tbody", nil)
|
||||
table.addChild(body)
|
||||
}
|
||||
row, err := renderTableRow(child, false, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body.addChild(row)
|
||||
}
|
||||
}
|
||||
return []*Node{table}, nil
|
||||
}
|
||||
|
||||
func renderTableRow(node gast.Node, header bool, source []byte) (*Node, error) {
|
||||
row := newElement("tr", nil)
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if child.Kind() != extast.KindTableCell {
|
||||
continue
|
||||
}
|
||||
cellAST := child.(*extast.TableCell)
|
||||
tag := "td"
|
||||
if header {
|
||||
tag = "th"
|
||||
}
|
||||
attrs := map[string]string(nil)
|
||||
switch cellAST.Alignment {
|
||||
case extast.AlignCenter:
|
||||
attrs = map[string]string{"align": "center"}
|
||||
case extast.AlignRight:
|
||||
attrs = map[string]string{"align": "right"}
|
||||
}
|
||||
cell := newElement(tag, attrs)
|
||||
content, err := renderInlineChildren(cellAST, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, inline := range content {
|
||||
cell.addChild(inline)
|
||||
}
|
||||
row.addChild(cell)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func renderDefinitionList(node gast.Node, source []byte) ([]*Node, error) {
|
||||
var out []*Node
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
switch child.Kind() {
|
||||
case extast.KindDefinitionTerm:
|
||||
fragment, err := renderInlineFragment(child, source, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := parseXML(fragment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paragraph := newElement("p", nil)
|
||||
bold := newElement("b", nil)
|
||||
for _, node := range nodes {
|
||||
bold.addChild(node)
|
||||
}
|
||||
paragraph.addChild(bold)
|
||||
out = append(out, paragraph)
|
||||
case extast.KindDefinitionDescription:
|
||||
quote, err := renderContainer("blockquote", nil, child, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, quote...)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func appendRawTextWithBreaks(parent *Node, content string) {
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
start := 0
|
||||
for i := 0; i < len(content); i++ {
|
||||
if content[i] != '\n' && content[i] != '\r' {
|
||||
continue
|
||||
}
|
||||
if i > start {
|
||||
parent.addChild(newText(content[start:i]))
|
||||
}
|
||||
if content[i] == '\r' && i+1 < len(content) && content[i+1] == '\n' {
|
||||
i++
|
||||
}
|
||||
parent.addChild(newElement("br", nil))
|
||||
start = i + 1
|
||||
}
|
||||
if start < len(content) {
|
||||
parent.addChild(newText(content[start:]))
|
||||
}
|
||||
}
|
||||
|
||||
func stripMarkdownEscapesInNodes(nodes []*Node, inCode, inLatex bool) {
|
||||
for _, node := range nodes {
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
if node.typ == nodeText {
|
||||
switch {
|
||||
case inCode:
|
||||
case inLatex:
|
||||
node.text = stripLatexMarkdownEscapes(node.text)
|
||||
default:
|
||||
node.text = stripBackslashEscapes(node.text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
stripMarkdownEscapesInNodes(node.children, inCode || node.tag == "code" || node.tag == "pre", inLatex || node.tag == "latex")
|
||||
}
|
||||
}
|
||||
|
||||
func stripBackslashEscapes(value string) string {
|
||||
if !strings.Contains(value, `\`) {
|
||||
return value
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(value))
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] == '\\' && i+1 < len(value) && isASCIIPunctuation(value[i+1]) {
|
||||
out.WriteByte(value[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out.WriteByte(value[i])
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func stripLatexMarkdownEscapes(value string) string {
|
||||
if !strings.Contains(value, `\`) {
|
||||
return value
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(value))
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] == '\\' && i+1 < len(value) && strings.ContainsRune("_^&*[]$~<>`#+-=:", rune(value[i+1])) {
|
||||
out.WriteByte(value[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out.WriteByte(value[i])
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func isASCIIPunctuation(ch byte) bool {
|
||||
return ch >= '!' && ch <= '/' || ch >= ':' && ch <= '@' || ch >= '[' && ch <= '`' || ch >= '{' && ch <= '~'
|
||||
}
|
||||
|
||||
func trimOneTrailingNewline(value string) string {
|
||||
if strings.HasSuffix(value, "\r\n") {
|
||||
return value[:len(value)-2]
|
||||
}
|
||||
return strings.TrimSuffix(value, "\n")
|
||||
}
|
||||
|
||||
func collectMarkdownChildText(node gast.Node, source []byte) string {
|
||||
var out strings.Builder
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
switch child.Kind() {
|
||||
case gast.KindText:
|
||||
out.Write(child.(*gast.Text).Value(source))
|
||||
case gast.KindString:
|
||||
out.Write(child.(*gast.String).Value)
|
||||
default:
|
||||
out.WriteString(collectMarkdownChildText(child, source))
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func extractMarkdownText(node gast.Node, source []byte) string {
|
||||
switch node.Kind() {
|
||||
case gast.KindText:
|
||||
return string(node.(*gast.Text).Value(source))
|
||||
case gast.KindString:
|
||||
return string(node.(*gast.String).Value)
|
||||
case gast.KindCodeSpan:
|
||||
return collectMarkdownChildText(node, source)
|
||||
}
|
||||
if node.Type() == gast.TypeBlock && node.Lines() != nil && node.Lines().Len() > 0 {
|
||||
return string(node.Lines().Value(source))
|
||||
}
|
||||
var out strings.Builder
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
out.WriteString(extractMarkdownText(child, source))
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func headingTag(level int) string {
|
||||
if level < 1 || level > 6 {
|
||||
return "p"
|
||||
}
|
||||
return fmt.Sprintf("h%d", level)
|
||||
}
|
||||
|
||||
func normalizeListIndent(markdown string) string {
|
||||
lines := strings.Split(markdown, "\n")
|
||||
type stackEntry struct{ indent int }
|
||||
var stack []stackEntry
|
||||
inFence := false
|
||||
changed := false
|
||||
lastOriginal, lastNormalized := 0, 0
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimLeft(line, " ")
|
||||
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
|
||||
inFence = !inFence
|
||||
continue
|
||||
}
|
||||
if inFence || trimmed == "" {
|
||||
continue
|
||||
}
|
||||
indent := len(line) - len(trimmed)
|
||||
if markdownListMarkerLength(trimmed) > 0 {
|
||||
for len(stack) > 0 && indent <= stack[len(stack)-1].indent {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
normalized := len(stack) * 4
|
||||
stack = append(stack, stackEntry{indent: indent})
|
||||
lastOriginal, lastNormalized = indent, normalized
|
||||
if indent != normalized {
|
||||
lines[i] = strings.Repeat(" ", normalized) + trimmed
|
||||
changed = true
|
||||
}
|
||||
} else if len(stack) > 0 && indent > lastOriginal {
|
||||
delta := lastNormalized - lastOriginal
|
||||
if delta != 0 {
|
||||
normalized := indent + delta
|
||||
if normalized < 0 {
|
||||
normalized = 0
|
||||
}
|
||||
lines[i] = strings.Repeat(" ", normalized) + trimmed
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return markdown
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func markdownListMarkerLength(value string) int {
|
||||
if len(value) >= 2 && (value[0] == '-' || value[0] == '*' || value[0] == '+') && value[1] == ' ' {
|
||||
return 2
|
||||
}
|
||||
i := 0
|
||||
for i < len(value) && value[i] >= '0' && value[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i > 0 && i+1 < len(value) && (value[i] == '.' || value[i] == ')') && value[i+1] == ' ' {
|
||||
return i + 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// preprocessCJKAdjacentMarkup disambiguates a narrow CommonMark pattern common in
|
||||
// Chinese prose: emphasis that ends in punctuation and is immediately followed
|
||||
// by a letter (for example **结论。**下一步). Goldmark correctly follows
|
||||
// CommonMark's delimiter rules, while LarkOpenCLI accepts this authoring form.
|
||||
// Rewriting simple CJK delimiter spans to equivalent DocxXML
|
||||
// before parsing removes the ambiguity while leaving nested Markdown, links,
|
||||
// code, fenced blocks, and source-bearing XML untouched.
|
||||
func preprocessCJKAdjacentMarkup(markdown string) string {
|
||||
if !strings.Contains(markdown, "**") && !strings.Contains(markdown, "~~") {
|
||||
return markdown
|
||||
}
|
||||
lines := strings.SplitAfter(markdown, "\n")
|
||||
var out strings.Builder
|
||||
fenceMarker := rune(0)
|
||||
fenceLength := 0
|
||||
rawSourceTag := ""
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimLeft(line, " \t>")
|
||||
if marker, length, ok := markdownFence(trimmed); ok {
|
||||
if fenceMarker == 0 {
|
||||
fenceMarker, fenceLength = marker, length
|
||||
} else if marker == fenceMarker && length >= fenceLength && strings.TrimSpace(runeTail(trimmed, length)) == "" {
|
||||
fenceMarker, fenceLength = 0, 0
|
||||
}
|
||||
out.WriteString(line)
|
||||
continue
|
||||
}
|
||||
if fenceMarker != 0 || leadingIndent(line) >= 4 {
|
||||
out.WriteString(line)
|
||||
continue
|
||||
}
|
||||
out.WriteString(rewriteCJKMarkupLine(line, &rawSourceTag))
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func markdownFence(line string) (rune, int, bool) {
|
||||
runes := []rune(line)
|
||||
if len(runes) < 3 || runes[0] != '`' && runes[0] != '~' {
|
||||
return 0, 0, false
|
||||
}
|
||||
marker := runes[0]
|
||||
length := 0
|
||||
for length < len(runes) && runes[length] == marker {
|
||||
length++
|
||||
}
|
||||
return marker, length, length >= 3
|
||||
}
|
||||
|
||||
func runeTail(value string, start int) string {
|
||||
runes := []rune(value)
|
||||
if start >= len(runes) {
|
||||
return ""
|
||||
}
|
||||
return string(runes[start:])
|
||||
}
|
||||
|
||||
func leadingIndent(line string) int {
|
||||
count := 0
|
||||
for _, r := range line {
|
||||
switch r {
|
||||
case ' ':
|
||||
count++
|
||||
case '\t':
|
||||
count += 4
|
||||
default:
|
||||
return count
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
type cjkMarkupRule struct {
|
||||
delimiter []rune
|
||||
openXML string
|
||||
closeXML string
|
||||
}
|
||||
|
||||
var cjkMarkupRules = []cjkMarkupRule{
|
||||
{delimiter: []rune("***"), openXML: "<em><b>", closeXML: "</b></em>"},
|
||||
{delimiter: []rune("~~"), openXML: "<del>", closeXML: "</del>"},
|
||||
{delimiter: []rune("**"), openXML: "<b>", closeXML: "</b>"},
|
||||
}
|
||||
|
||||
func rewriteCJKMarkupLine(line string, rawSourceTag *string) string {
|
||||
if *rawSourceTag != "" {
|
||||
runes := []rune(line)
|
||||
closeTag := []rune("</" + *rawSourceTag + ">")
|
||||
closeAt := indexRunesFold(runes, 0, closeTag)
|
||||
if closeAt < 0 {
|
||||
return line
|
||||
}
|
||||
closeEnd := closeAt + len(closeTag)
|
||||
prefix := string(runes[:closeEnd])
|
||||
*rawSourceTag = ""
|
||||
return prefix + rewriteCJKMarkupLine(string(runes[closeEnd:]), rawSourceTag)
|
||||
}
|
||||
|
||||
runes := []rune(line)
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(runes); {
|
||||
if runes[i] == '`' && !runeEscaped(runes, i) {
|
||||
if end := codeSpanEnd(runes, i); end > i {
|
||||
out.WriteString(string(runes[i:end]))
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
}
|
||||
if runes[i] == '<' {
|
||||
if tag, end, selfClosing, ok := rawTagAt(runes, i); ok {
|
||||
out.WriteString(string(runes[i:end]))
|
||||
i = end
|
||||
if !selfClosing && (tag == "code" || tag == "pre" || tag == "whiteboard") {
|
||||
close := []rune("</" + tag + ">")
|
||||
if closeAt := indexRunesFold(runes, i, close); closeAt >= 0 {
|
||||
closeEnd := closeAt + len(close)
|
||||
out.WriteString(string(runes[i:closeEnd]))
|
||||
i = closeEnd
|
||||
} else {
|
||||
out.WriteString(string(runes[i:]))
|
||||
*rawSourceTag = tag
|
||||
return out.String()
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
rewritten := false
|
||||
for _, rule := range cjkMarkupRules {
|
||||
if !exactDelimiterAt(runes, i, rule.delimiter) || runeEscaped(runes, i) {
|
||||
continue
|
||||
}
|
||||
closeAt := delimiterCloser(runes, i+len(rule.delimiter), rule.delimiter)
|
||||
if closeAt < 0 {
|
||||
continue
|
||||
}
|
||||
content := runes[i+len(rule.delimiter) : closeAt]
|
||||
if !shouldRewriteCJKMarkup(content) {
|
||||
continue
|
||||
}
|
||||
out.WriteString(rule.openXML)
|
||||
out.WriteString(escapeXMLText(stripBackslashEscapes(string(content))))
|
||||
out.WriteString(rule.closeXML)
|
||||
i = closeAt + len(rule.delimiter)
|
||||
rewritten = true
|
||||
break
|
||||
}
|
||||
if rewritten {
|
||||
continue
|
||||
}
|
||||
out.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func rawTagAt(runes []rune, start int) (tag string, end int, selfClosing, ok bool) {
|
||||
if start+1 >= len(runes) || !isASCIILetterRune(runes[start+1]) {
|
||||
return "", 0, false, false
|
||||
}
|
||||
i := start + 1
|
||||
for i < len(runes) && (isASCIILetterRune(runes[i]) || isASCIIDigitRune(runes[i]) || runes[i] == '-' || runes[i] == '_') {
|
||||
i++
|
||||
}
|
||||
tag = strings.ToLower(string(runes[start+1 : i]))
|
||||
quote := rune(0)
|
||||
for ; i < len(runes); i++ {
|
||||
if runes[i] == '\'' || runes[i] == '"' {
|
||||
if quote == 0 {
|
||||
quote = runes[i]
|
||||
} else if quote == runes[i] {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
if runes[i] == '>' && quote == 0 {
|
||||
trimmed := strings.TrimSpace(string(runes[start : i+1]))
|
||||
return tag, i + 1, strings.HasSuffix(trimmed, "/>"), true
|
||||
}
|
||||
}
|
||||
return "", 0, false, false
|
||||
}
|
||||
|
||||
func indexRunesFold(haystack []rune, start int, needle []rune) int {
|
||||
for i := start; i+len(needle) <= len(haystack); i++ {
|
||||
if strings.EqualFold(string(haystack[i:i+len(needle)]), string(needle)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func codeSpanEnd(runes []rune, open int) int {
|
||||
length := 0
|
||||
for open+length < len(runes) && runes[open+length] == '`' {
|
||||
length++
|
||||
}
|
||||
for i := open + length; i < len(runes); i++ {
|
||||
if runes[i] != '`' || runeEscaped(runes, i) {
|
||||
continue
|
||||
}
|
||||
end := i
|
||||
for end < len(runes) && runes[end] == '`' {
|
||||
end++
|
||||
}
|
||||
if end-i == length {
|
||||
return end
|
||||
}
|
||||
i = end - 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func exactDelimiterAt(runes []rune, start int, delimiter []rune) bool {
|
||||
if start+len(delimiter) > len(runes) {
|
||||
return false
|
||||
}
|
||||
for i, want := range delimiter {
|
||||
if runes[start+i] != want {
|
||||
return false
|
||||
}
|
||||
}
|
||||
marker := delimiter[0]
|
||||
return (start == 0 || runes[start-1] != marker) && (start+len(delimiter) == len(runes) || runes[start+len(delimiter)] != marker)
|
||||
}
|
||||
|
||||
func delimiterCloser(runes []rune, start int, delimiter []rune) int {
|
||||
for i := start; i+len(delimiter) <= len(runes); i++ {
|
||||
if runes[i] == '\n' {
|
||||
return -1
|
||||
}
|
||||
if exactDelimiterAt(runes, i, delimiter) && !runeEscaped(runes, i) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func shouldRewriteCJKMarkup(content []rune) bool {
|
||||
if len(content) == 0 || unicode.IsSpace(content[0]) || unicode.IsSpace(content[len(content)-1]) {
|
||||
return false
|
||||
}
|
||||
for _, r := range content {
|
||||
if r == '`' || r == '[' || r == ']' || r == '<' || r == '>' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !containsCJK(content) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsCJK(value []rune) bool {
|
||||
for _, r := range value {
|
||||
if isCJKRune(r) || r > unicode.MaxASCII && (unicode.IsPunct(r) || unicode.IsSymbol(r)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isCJKRune(r rune) bool {
|
||||
return unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul)
|
||||
}
|
||||
|
||||
func runeEscaped(runes []rune, index int) bool {
|
||||
count := 0
|
||||
for i := index - 1; i >= 0 && runes[i] == '\\'; i-- {
|
||||
count++
|
||||
}
|
||||
return count%2 == 1
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
// This file contains the small Goldmark extensions needed to match the
|
||||
// LarkOpenCLI's Markdown surface: math, DocxXML tag names containing
|
||||
// underscores, and Markdown-aware callout/grid/column containers.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
gmutil "github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// ---------- Math ----------
|
||||
|
||||
var kindMathInline = gast.NewNodeKind("DocxMathInline")
|
||||
var kindMathBlock = gast.NewNodeKind("DocxMathBlock")
|
||||
|
||||
type mathInline struct {
|
||||
gast.BaseInline
|
||||
content []byte
|
||||
}
|
||||
|
||||
func (n *mathInline) Kind() gast.NodeKind { return kindMathInline }
|
||||
func (n *mathInline) Dump(source []byte, level int) {
|
||||
gast.DumpHelper(n, source, level, nil, nil)
|
||||
}
|
||||
|
||||
type mathBlock struct {
|
||||
gast.BaseInline
|
||||
content []byte
|
||||
}
|
||||
|
||||
func (n *mathBlock) Kind() gast.NodeKind { return kindMathBlock }
|
||||
func (n *mathBlock) Dump(source []byte, level int) {
|
||||
gast.DumpHelper(n, source, level, nil, nil)
|
||||
}
|
||||
|
||||
var (
|
||||
mathBlockMultiLine = regexp.MustCompile(`(?s)^\$\$(.+?)\$\$`)
|
||||
mathInlineMultiLine = regexp.MustCompile(`(?s)^\$([^ \t$].*?)\$`)
|
||||
)
|
||||
|
||||
type mathInlineParser struct{}
|
||||
|
||||
func (p *mathInlineParser) Trigger() []byte { return []byte{'$'} }
|
||||
|
||||
func (p *mathInlineParser) Parse(_ gast.Node, reader text.Reader, _ parser.Context) gast.Node {
|
||||
line, _ := reader.PeekLine()
|
||||
if len(line) == 0 || line[0] != '$' {
|
||||
return nil
|
||||
}
|
||||
if len(line) >= 2 && line[1] == '$' {
|
||||
if content, advance := scanMathClose(line[2:], "$$"); advance >= 0 && len(content) > 0 {
|
||||
reader.Advance(2 + advance)
|
||||
return &mathBlock{content: append([]byte(nil), content...)}
|
||||
}
|
||||
match := reader.FindSubMatch(mathBlockMultiLine)
|
||||
if len(match) >= 2 && len(bytes.TrimSpace(match[1])) > 0 && !bytes.Contains(match[1], []byte("<latex")) {
|
||||
return &mathBlock{content: append([]byte(nil), bytes.TrimSpace(match[1])...)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(line) < 2 || line[1] == ' ' || line[1] == '\t' || line[1] == '$' {
|
||||
return nil
|
||||
}
|
||||
if content, advance := scanMathClose(line[1:], "$"); advance >= 0 && len(content) > 0 {
|
||||
if content[len(content)-1] == ' ' || content[len(content)-1] == '\t' {
|
||||
return nil
|
||||
}
|
||||
reader.Advance(1 + advance)
|
||||
return &mathInline{content: append([]byte(nil), content...)}
|
||||
}
|
||||
match := reader.FindSubMatch(mathInlineMultiLine)
|
||||
if len(match) < 2 || bytes.Contains(match[1], []byte("<latex")) {
|
||||
return nil
|
||||
}
|
||||
trimmed := bytes.TrimRight(match[1], "\n\r")
|
||||
if len(trimmed) == 0 || trimmed[len(trimmed)-1] == ' ' || trimmed[len(trimmed)-1] == '\t' {
|
||||
return nil
|
||||
}
|
||||
return &mathInline{content: append([]byte(nil), trimmed...)}
|
||||
}
|
||||
|
||||
func scanMathClose(data []byte, delimiter string) ([]byte, int) {
|
||||
delim := []byte(delimiter)
|
||||
for offset := 0; offset < len(data); {
|
||||
if data[offset] == '\\' && offset+1 < len(data) && data[offset+1] == '$' {
|
||||
offset += 2
|
||||
continue
|
||||
}
|
||||
rel := bytes.Index(data[offset:], delim)
|
||||
if rel < 0 {
|
||||
return nil, -1
|
||||
}
|
||||
end := offset + rel
|
||||
if bytes.Contains(data[:end], []byte("<latex")) {
|
||||
return nil, -1
|
||||
}
|
||||
return data[:end], end + len(delim)
|
||||
}
|
||||
return nil, -1
|
||||
}
|
||||
|
||||
type mathExtension struct{}
|
||||
|
||||
func (e *mathExtension) Extend(markdown goldmark.Markdown) {
|
||||
markdown.Parser().AddOptions(parser.WithInlineParsers(
|
||||
gmutil.Prioritized(&mathInlineParser{}, 100),
|
||||
))
|
||||
}
|
||||
|
||||
// ---------- Underscore-bearing raw XML tags ----------
|
||||
|
||||
type underscoreHTMLExtension struct{}
|
||||
|
||||
func (e *underscoreHTMLExtension) Extend(markdown goldmark.Markdown) {
|
||||
markdown.Parser().AddOptions(
|
||||
parser.WithInlineParsers(gmutil.Prioritized(&underscoreRawHTMLParser{}, 99)),
|
||||
parser.WithBlockParsers(gmutil.Prioritized(&underscoreHTMLBlockParser{}, 99)),
|
||||
)
|
||||
}
|
||||
|
||||
var (
|
||||
extendedTagNamePattern = `([A-Za-z][A-Za-z0-9_-]*)`
|
||||
extendedAttributePattern = `(?:\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\s*=\s*(?:[^"'=<>` + "`" + `\x00-\x20]+|'[^']*'|"[^"]*"))?)`
|
||||
extendedOpenTag = regexp.MustCompile("^<" + extendedTagNamePattern + extendedAttributePattern + `*\s*/?>`)
|
||||
extendedCloseTag = regexp.MustCompile("^</" + extendedTagNamePattern + `\s*>`)
|
||||
peekExtendedOpenTag = regexp.MustCompile(`^<([A-Za-z][A-Za-z0-9_-]*)`)
|
||||
peekExtendedCloseTag = regexp.MustCompile(`^</([A-Za-z][A-Za-z0-9_-]*)`)
|
||||
extendedBlockTag = regexp.MustCompile(`^[ ]{0,3}<(/)?\s*([a-zA-Z0-9_\-]+)(` + extendedAttributePattern + `*)\s*(?:>|/>)\s*\n?$`)
|
||||
)
|
||||
|
||||
type underscoreRawHTMLParser struct{}
|
||||
|
||||
func (p *underscoreRawHTMLParser) Trigger() []byte { return []byte{'<'} }
|
||||
|
||||
func (p *underscoreRawHTMLParser) Parse(_ gast.Node, reader text.Reader, _ parser.Context) gast.Node {
|
||||
line, _ := reader.PeekLine()
|
||||
if len(line) > 1 && gmutil.IsAlphaNumeric(line[1]) {
|
||||
if match := peekExtendedOpenTag.FindSubmatch(line); match != nil && bytes.IndexByte(match[1], '_') >= 0 {
|
||||
return p.parseMultiLine(extendedOpenTag, reader)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(line) > 2 && line[1] == '/' && gmutil.IsAlphaNumeric(line[2]) {
|
||||
if match := peekExtendedCloseTag.FindSubmatch(line); match != nil && bytes.IndexByte(match[1], '_') >= 0 {
|
||||
return p.parseMultiLine(extendedCloseTag, reader)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *underscoreRawHTMLParser) parseMultiLine(re *regexp.Regexp, reader text.Reader) gast.Node {
|
||||
startLine, startSegment := reader.Position()
|
||||
if !reader.Match(re) {
|
||||
return nil
|
||||
}
|
||||
endLine, endSegment := reader.Position()
|
||||
reader.SetPosition(startLine, startSegment)
|
||||
node := gast.NewRawHTML()
|
||||
for {
|
||||
line, segment := reader.PeekLine()
|
||||
if line == nil {
|
||||
break
|
||||
}
|
||||
lineNo, _ := reader.Position()
|
||||
start := segment.Start
|
||||
if lineNo == startLine {
|
||||
start = startSegment.Start
|
||||
}
|
||||
end := segment.Stop
|
||||
if lineNo == endLine {
|
||||
end = endSegment.Start
|
||||
}
|
||||
node.Segments.Append(text.NewSegment(start, end))
|
||||
if lineNo == endLine {
|
||||
reader.Advance(end - start)
|
||||
break
|
||||
}
|
||||
reader.AdvanceLine()
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
type underscoreHTMLBlockParser struct{}
|
||||
|
||||
func (p *underscoreHTMLBlockParser) Trigger() []byte { return []byte{'<'} }
|
||||
|
||||
func (p *underscoreHTMLBlockParser) Open(_ gast.Node, reader text.Reader, pc parser.Context) (gast.Node, parser.State) {
|
||||
line, segment := reader.PeekLine()
|
||||
pos := pc.BlockOffset()
|
||||
if pos < 0 || pos >= len(line) || line[pos] != '<' {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
match := extendedBlockTag.FindSubmatchIndex(line)
|
||||
if match == nil {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
tag := string(line[match[4]:match[5]])
|
||||
if !strings.Contains(tag, "_") {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
isClose := match[2] > -1 && bytes.Equal(line[match[2]:match[3]], []byte("/"))
|
||||
hasAttrs := match[6] != match[7]
|
||||
if isClose && hasAttrs {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
node := gast.NewHTMLBlock(gast.HTMLBlockType7)
|
||||
node.Lines().Append(segment)
|
||||
reader.Advance(segment.Len() - 1)
|
||||
return node, parser.NoChildren
|
||||
}
|
||||
|
||||
func (p *underscoreHTMLBlockParser) Continue(node gast.Node, reader text.Reader, _ parser.Context) parser.State {
|
||||
line, segment := reader.PeekLine()
|
||||
if gmutil.IsBlank(line) {
|
||||
return parser.Close
|
||||
}
|
||||
node.Lines().Append(segment)
|
||||
reader.Advance(segment.Len() - 1)
|
||||
return parser.Continue | parser.NoChildren
|
||||
}
|
||||
|
||||
func (p *underscoreHTMLBlockParser) Close(gast.Node, text.Reader, parser.Context) {}
|
||||
func (p *underscoreHTMLBlockParser) CanInterruptParagraph() bool { return false }
|
||||
func (p *underscoreHTMLBlockParser) CanAcceptIndentedLine() bool { return false }
|
||||
|
||||
// ---------- Markdown-aware DocxXML containers ----------
|
||||
|
||||
type containerSpec struct {
|
||||
tag string
|
||||
}
|
||||
|
||||
var containerSpecs = map[string]*containerSpec{
|
||||
"callout": {tag: "callout"},
|
||||
"grid": {tag: "grid"},
|
||||
"column": {tag: "column"},
|
||||
"div": {tag: "div"},
|
||||
}
|
||||
|
||||
var kindContainerBlock = gast.NewNodeKind("DocxContainerBlock")
|
||||
|
||||
type containerBlock struct {
|
||||
gast.BaseBlock
|
||||
spec *containerSpec
|
||||
attrs map[string]string
|
||||
}
|
||||
|
||||
func (n *containerBlock) Kind() gast.NodeKind { return kindContainerBlock }
|
||||
func (n *containerBlock) Dump(source []byte, level int) {
|
||||
gast.DumpHelper(n, source, level, nil, nil)
|
||||
}
|
||||
|
||||
type containerBlockParser struct{}
|
||||
|
||||
func (p *containerBlockParser) Trigger() []byte { return []byte{'<'} }
|
||||
|
||||
var containerOpenTag = regexp.MustCompile(`^<([A-Za-z][A-Za-z0-9_-]*)`)
|
||||
|
||||
func (p *containerBlockParser) Open(_ gast.Node, reader text.Reader, _ parser.Context) (gast.Node, parser.State) {
|
||||
line, _ := reader.PeekLine()
|
||||
trimmed := bytes.TrimLeft(line, " \t")
|
||||
leading := len(line) - len(trimmed)
|
||||
if len(trimmed) < 2 || trimmed[0] != '<' {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
match := containerOpenTag.FindSubmatch(trimmed)
|
||||
if match == nil {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
spec := containerSpecs[strings.ToLower(string(match[1]))]
|
||||
if spec == nil {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
openEnd := bytes.IndexByte(trimmed, '>')
|
||||
if openEnd < 0 || openEnd >= 1 && trimmed[openEnd-1] == '/' {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
tagEnd := len(match[0])
|
||||
node := &containerBlock{spec: spec, attrs: parseAttributes(string(trimmed[tagEnd:openEnd]))}
|
||||
reader.Advance(leading + openEnd + 1)
|
||||
return node, parser.HasChildren
|
||||
}
|
||||
|
||||
func (p *containerBlockParser) Continue(node gast.Node, reader text.Reader, _ parser.Context) parser.State {
|
||||
container := node.(*containerBlock)
|
||||
line, segment := reader.PeekLine()
|
||||
trimmed := bytes.TrimLeft(line, " \t")
|
||||
if hasCloseTagPrefix(trimmed, container.spec.tag) {
|
||||
reader.Advance(len(line) - len(trimmed) + closeTagLength(container.spec.tag))
|
||||
return parser.Close
|
||||
}
|
||||
if isXMLTagLine(trimmed) {
|
||||
indent := len(line) - len(trimmed)
|
||||
if indent > 0 && segment.Start+indent <= segment.Stop {
|
||||
reader.AdvanceAndSetPadding(indent, 0)
|
||||
}
|
||||
}
|
||||
return parser.Continue | parser.HasChildren
|
||||
}
|
||||
|
||||
func (p *containerBlockParser) Close(gast.Node, text.Reader, parser.Context) {}
|
||||
func (p *containerBlockParser) CanInterruptParagraph() bool { return true }
|
||||
func (p *containerBlockParser) CanAcceptIndentedLine() bool { return true }
|
||||
|
||||
func closeTagLength(tag string) int { return len(tag) + len("</>") }
|
||||
|
||||
func hasCloseTagPrefix(line []byte, tag string) bool {
|
||||
want := []byte("</" + tag + ">")
|
||||
return len(line) >= len(want) && bytes.EqualFold(line[:len(want)], want)
|
||||
}
|
||||
|
||||
func isXMLTagLine(line []byte) bool {
|
||||
if len(line) < 2 || line[0] != '<' {
|
||||
return false
|
||||
}
|
||||
if line[1] == '/' {
|
||||
return len(line) >= 3 && isASCIILetter(line[2])
|
||||
}
|
||||
return isASCIILetter(line[1])
|
||||
}
|
||||
|
||||
func isASCIILetter(ch byte) bool {
|
||||
return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z'
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package docxparse parses LarkOpenCLI DocxXML and Markdown into a small,
|
||||
// offline DOM for the docs +script shortcut.
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Format is an accepted source document format.
|
||||
type Format string
|
||||
|
||||
const (
|
||||
FormatXML Format = "xml"
|
||||
FormatMarkdown Format = "markdown"
|
||||
)
|
||||
|
||||
// ParseResult is the complete result returned by Parse.
|
||||
type ParseResult struct {
|
||||
Format Format `json:"format"`
|
||||
XML string `json:"xml"`
|
||||
Profile Profile `json:"profile"`
|
||||
}
|
||||
|
||||
type nodeType uint8
|
||||
|
||||
const (
|
||||
nodeText nodeType = iota
|
||||
nodeElement
|
||||
)
|
||||
|
||||
// Node is the internal DocxXML DOM representation.
|
||||
type Node struct {
|
||||
typ nodeType
|
||||
tag string
|
||||
attrs map[string]string
|
||||
children []*Node
|
||||
text string
|
||||
parent *Node
|
||||
}
|
||||
|
||||
func newText(text string) *Node {
|
||||
return &Node{typ: nodeText, text: text}
|
||||
}
|
||||
|
||||
func newElement(tag string, attrs map[string]string) *Node {
|
||||
return &Node{typ: nodeElement, tag: tag, attrs: attrs}
|
||||
}
|
||||
|
||||
func (n *Node) addChild(child *Node) {
|
||||
if n == nil || child == nil {
|
||||
return
|
||||
}
|
||||
child.parent = n
|
||||
n.children = append(n.children, child)
|
||||
}
|
||||
|
||||
func (n *Node) writeXML(out *strings.Builder) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
if n.typ == nodeText {
|
||||
out.WriteString(escapeXMLText(n.text))
|
||||
return
|
||||
}
|
||||
|
||||
out.WriteByte('<')
|
||||
out.WriteString(n.tag)
|
||||
keys := make([]string, 0, len(n.attrs))
|
||||
for key := range n.attrs {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
wi, iWeighted := attributeWeight[keys[i]]
|
||||
wj, jWeighted := attributeWeight[keys[j]]
|
||||
switch {
|
||||
case iWeighted && jWeighted && wi != wj:
|
||||
return wi < wj
|
||||
case iWeighted != jWeighted:
|
||||
return iWeighted
|
||||
default:
|
||||
return keys[i] < keys[j]
|
||||
}
|
||||
})
|
||||
for _, key := range keys {
|
||||
out.WriteByte(' ')
|
||||
out.WriteString(key)
|
||||
out.WriteString(`="`)
|
||||
out.WriteString(escapeXMLAttr(n.attrs[key]))
|
||||
out.WriteByte('"')
|
||||
}
|
||||
|
||||
if isVoidTag(n.tag) {
|
||||
out.WriteString("/>")
|
||||
return
|
||||
}
|
||||
out.WriteByte('>')
|
||||
for _, child := range n.children {
|
||||
child.writeXML(out)
|
||||
}
|
||||
out.WriteString("</")
|
||||
out.WriteString(n.tag)
|
||||
out.WriteByte('>')
|
||||
}
|
||||
|
||||
func renderNodes(nodes []*Node) string {
|
||||
var out strings.Builder
|
||||
for _, node := range nodes {
|
||||
node.writeXML(&out)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
var attributeWeight = map[string]int{
|
||||
"id": 0,
|
||||
"name": 1,
|
||||
"top-block-id": 2,
|
||||
"parent-block-path": 3,
|
||||
"mode": 4,
|
||||
"start-block-id": 5,
|
||||
"end-block-id": 6,
|
||||
"hit-block-ids": 7,
|
||||
}
|
||||
|
||||
func escapeXMLText(value string) string {
|
||||
if !strings.ContainsAny(value, "&<>") {
|
||||
return value
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(value) + 8)
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '&':
|
||||
out.WriteString("&")
|
||||
case '<':
|
||||
out.WriteString("<")
|
||||
case '>':
|
||||
out.WriteString(">")
|
||||
default:
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func escapeXMLAttr(value string) string {
|
||||
if !strings.ContainsAny(value, "&<>\"'") {
|
||||
return value
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(value) + 8)
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '&':
|
||||
out.WriteString("&")
|
||||
case '<':
|
||||
out.WriteString("<")
|
||||
case '>':
|
||||
out.WriteString(">")
|
||||
case '"':
|
||||
out.WriteString(""")
|
||||
case '\'':
|
||||
out.WriteString("'")
|
||||
default:
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseXMLBuildsBlockDistribution(t *testing.T) {
|
||||
result, err := Parse(`<title>T</title><p>P</p><ul><li>A</li><li>B</li></ul>`, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != `<title>T</title><p>P</p><ul><li>A</li><li>B</li></ul>` {
|
||||
t.Fatalf("XML = %q", result.XML)
|
||||
}
|
||||
if result.Profile.BlockCount != 5 {
|
||||
t.Fatalf("block total = %d, want 5", result.Profile.BlockCount)
|
||||
}
|
||||
shares := map[string]BlockShare{}
|
||||
for _, share := range result.Profile.Blocks {
|
||||
shares[share.Type] = share
|
||||
}
|
||||
if got := shares["li"]; got.Count != 2 || got.Ratio != 0.4 {
|
||||
t.Fatalf("li share = %+v, want count=2 ratio=0.4", got)
|
||||
}
|
||||
for _, typ := range []string{"title", "p", "ul"} {
|
||||
if got := shares[typ]; got.Count != 1 || got.Ratio != 0.2 {
|
||||
t.Errorf("%s share = %+v, want count=1 ratio=0.2", typ, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLRejectsInvalidInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
}{
|
||||
{name: "unsupported tag", source: `<unknown>x</unknown>`},
|
||||
{name: "missing closing tag", source: `<p>one`},
|
||||
{name: "invalid nesting", source: `<span>x<table><tr><td>y</td></tr></table></span>`},
|
||||
{name: "malformed block id", source: `<block_id="8,9"/>`},
|
||||
{name: "unterminated cdata", source: `<code><![CDATA[a < b</code>`},
|
||||
{name: "tag spacing", source: `< p>text< / p>`},
|
||||
{name: "self closing slash spacing", source: `<p/ >`},
|
||||
{name: "unquoted attribute", source: `<p align=center>text</p>`},
|
||||
{name: "invalid entity", source: `<p>one &unknown;</p>`},
|
||||
{name: "invalid attribute entity", source: `<img href="https://example.com/&unknown;"/>`},
|
||||
{name: "missing required ancestor", source: `<td>cell</td>`},
|
||||
{name: "missing required attribute", source: `<img/>`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := Parse(tt.source, FormatXML); err == nil {
|
||||
t.Fatalf("Parse(%q) succeeded, want validation error", tt.source)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAutoDetectsXMLAndMarkdown(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
blocks int
|
||||
}{
|
||||
{name: "xml", source: `<title>T</title><p>P</p>`, blocks: 2},
|
||||
{name: "markdown", source: "# T\n\nP", blocks: 2},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile, err := ParseAuto(tt.source)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAuto() error = %v", err)
|
||||
}
|
||||
if profile.BlockCount != tt.blocks {
|
||||
t.Fatalf("profile = %+v, want %d blocks", profile, tt.blocks)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAutoDoesNotTreatMalformedXMLAsMarkdown(t *testing.T) {
|
||||
if _, err := ParseAuto(`<p>text`); err == nil {
|
||||
t.Fatal("ParseAuto() succeeded, want malformed XML error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLAcceptsPublicTagAliasesWithoutChangingInput(t *testing.T) {
|
||||
source := `<P>one<strong>two</strong><br></P><image href="https://example.com/image.png">`
|
||||
result, err := Parse(source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != source {
|
||||
t.Fatalf("XML = %q, want original %q", result.XML, source)
|
||||
}
|
||||
if result.Profile.BlockCount != 2 {
|
||||
t.Fatalf("profile = %+v, want p and img blocks", result.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLAcceptsPublicAttributeAliasesWithoutChangingInput(t *testing.T) {
|
||||
source := `<callout color="blue" icon="💡"><p>x</p></callout><at id="ou_legacy"></at><img url="https://example.com/image.png"/>`
|
||||
result, err := Parse(source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != source {
|
||||
t.Fatalf("XML = %q, want original %q", result.XML, source)
|
||||
}
|
||||
if result.Profile.BlockCount != 3 {
|
||||
t.Fatalf("profile = %+v, want callout, p, and img blocks", result.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLAcceptsBareAmpersandsInAttributes(t *testing.T) {
|
||||
source := `<block_insert><parameter><block_id>-1</block_id><content><img href="https://picsum.photos/320/200?seed=lark-cli&raw=1"/></content></parameter></block_insert>`
|
||||
result, err := Parse(source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != source {
|
||||
t.Fatalf("XML = %q, want original %q", result.XML, source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeXMLAttributeAmpersandsPreservesEntityReferences(t *testing.T) {
|
||||
source := `https://example.com?a=1&b=2&c=3&d=4&e=5&unknown;`
|
||||
want := `https://example.com?a=1&b=2&c=3&d=4&e=5&unknown;`
|
||||
if got := normalizeXMLAttributeAmpersands(source); got != want {
|
||||
t.Fatalf("normalizeXMLAttributeAmpersands() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLPreservesValidCDATA(t *testing.T) {
|
||||
source := `<code><![CDATA[a < b && c > d]]></code>`
|
||||
result, err := Parse(source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != source {
|
||||
t.Fatalf("XML = %q, want original %q", result.XML, source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLPreservesUTF8BOM(t *testing.T) {
|
||||
source := "\uFEFF<p>text</p>"
|
||||
result, err := Parse(source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != source {
|
||||
t.Fatalf("XML = %q, want original input", result.XML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownConvertsLarkOpenCLIBlocks(t *testing.T) {
|
||||
source := "# 标题\n\nHello **world**.\n\n- [x] Done\n- [ ] Todo\n\n" +
|
||||
"| A | B |\n| --- | --- |\n| 1 | 2 |\n\n" +
|
||||
"```go\nfmt.Println(\"x\")\n```\n\n$E=mc^2$\n"
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
`<h1>标题</h1>`,
|
||||
`<p>Hello <b>world</b>.</p>`,
|
||||
`<checkbox done="true">Done</checkbox>`,
|
||||
`<checkbox done="false">Todo</checkbox>`,
|
||||
`<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>`,
|
||||
`<pre lang="go"><code>fmt.Println("x")</code></pre>`,
|
||||
`<p><latex>E=mc^2</latex></p>`,
|
||||
} {
|
||||
if !strings.Contains(result.XML, fragment) {
|
||||
t.Errorf("XML missing %q:\n%s", fragment, result.XML)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownPreservesLineBreakSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "soft breaks become spaces",
|
||||
source: "**文号:桂汛旱指〔2026〕17号**\n**签发人:XXX**\n**发布日期:2026年7月13日**",
|
||||
want: `<p><b>文号:桂汛旱指〔2026〕17号</b> <b>签发人:XXX</b> <b>发布日期:2026年7月13日</b></p>`,
|
||||
},
|
||||
{
|
||||
name: "hard breaks remain line breaks",
|
||||
source: "**文号:A** \n**签发人:B**",
|
||||
want: `<p><b>文号:A</b><br/><b>签发人:B</b></p>`,
|
||||
},
|
||||
{
|
||||
name: "blank lines remain paragraph breaks",
|
||||
source: "**文号:A**\n\n**签发人:B**",
|
||||
want: `<p><b>文号:A</b></p><p><b>签发人:B</b></p>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := Parse(tt.source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != tt.want {
|
||||
t.Fatalf("XML = %q, want %q", result.XML, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownContainerKeepsMarkdownChildren(t *testing.T) {
|
||||
source := "<callout emoji=\"💡\">\n\n## Note\n\n- item\n\n</callout>\n"
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
want := `<callout emoji="💡"><h2>Note</h2><ul><li>item</li></ul></callout>`
|
||||
if result.XML != want {
|
||||
t.Fatalf("XML = %q, want %q", result.XML, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownMatchesLarkOpenCLIFixtures(t *testing.T) {
|
||||
t.Run("deep nested list", func(t *testing.T) {
|
||||
result, err := Parse("1. 第一层\n - 第二层\n - 第三层\n - 第四层\n", FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if strings.Contains(result.XML, "<pre>") || strings.Contains(result.XML, "<code>") || !strings.Contains(result.XML, "第四层") {
|
||||
t.Fatalf("nested list converted incorrectly: %s", result.XML)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fenced mermaid", func(t *testing.T) {
|
||||
result, err := Parse("```mermaid\nflowchart LR\nA-->B\n```", FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
want := `<whiteboard type="mermaid">flowchart LR<br/>A-->B</whiteboard>`
|
||||
if result.XML != want {
|
||||
t.Fatalf("XML = %q, want %q", result.XML, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("raw whiteboard source", func(t *testing.T) {
|
||||
source := "<whiteboard type=\"mermaid\">\nflowchart LR\n A --> B\n</whiteboard>"
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
want := `<whiteboard type="mermaid">flowchart LR<br/> A --> B</whiteboard>`
|
||||
if result.XML != want {
|
||||
t.Fatalf("XML = %q, want %q", result.XML, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("raw code stays literal", func(t *testing.T) {
|
||||
source := "<code lang=\"go\">\nif a < b && c > d {\n fmt.Println(\"**raw**\")\n}\n</code>"
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
want := `<code lang="go">if a < b && c > d {<br/> fmt.Println("**raw**")<br/>}</code>`
|
||||
if result.XML != want {
|
||||
t.Fatalf("XML = %q, want %q", result.XML, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("underscore tags", func(t *testing.T) {
|
||||
result, err := Parse(`text <synced_reference src-block-id="abc" src-token="def"/> more`, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(result.XML, `<synced_reference`) || strings.Contains(result.XML, `<synced_reference`) {
|
||||
t.Fatalf("underscore tag was not preserved: %s", result.XML)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("canonical user cite", func(t *testing.T) {
|
||||
result, err := Parse(`hello <cite type="user" user-id="ou_user"></cite>`, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{`<cite`, `type="user"`, `user-id="ou_user"`} {
|
||||
if !strings.Contains(result.XML, want) {
|
||||
t.Errorf("XML missing %q: %s", want, result.XML)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public tag alias converts to canonical XML", func(t *testing.T) {
|
||||
result, err := Parse(`hello <strong>world</strong>`, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != `<p>hello <b>world</b></p>` {
|
||||
t.Fatalf("XML = %q", result.XML)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public cite alias converts attributes", func(t *testing.T) {
|
||||
result, err := Parse(`hello <at id="ou_legacy"></at>`, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != `<p>hello <cite type="user" user-id="ou_legacy"></cite></p>` {
|
||||
t.Fatalf("XML = %q", result.XML)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("markdown backslash escapes", func(t *testing.T) {
|
||||
result, err := Parse(`"source\_token": \[abc\] path\\to`, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{`source_token`, `[abc]`, `path\to`} {
|
||||
if !strings.Contains(result.XML, want) {
|
||||
t.Errorf("XML missing %q: %s", want, result.XML)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("adjacent CJK emphasis", func(t *testing.T) {
|
||||
source := `***你好。***S 和 ~~再见。~~T。**agent team 做 brownfield 项目,带来的感知会强烈得多**——前提。**这个时刻,才是真正属于 agent team 的"闪光时刻"。**翟霖`
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`<em><b>你好。</b></em>S`,
|
||||
`<del>再见。</del>T`,
|
||||
`<b>agent team 做 brownfield 项目,带来的感知会强烈得多</b>`,
|
||||
`<b>这个时刻,才是真正属于 agent team 的"闪光时刻"。</b>翟霖`,
|
||||
} {
|
||||
if !strings.Contains(result.XML, want) {
|
||||
t.Errorf("XML missing %q: %s", want, result.XML)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("div parses markdown children", func(t *testing.T) {
|
||||
result, err := Parse("<div>\n\n**bold**\n\n</div>", FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != `<div><p><b>bold</b></p></div>` {
|
||||
t.Fatalf("XML = %q", result.XML)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPreprocessCJKAdjacentMarkupUsesRuneOffsetsAfterRawBlock(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lineEnding string
|
||||
final string
|
||||
}{
|
||||
{name: "EOF", lineEnding: "\n"},
|
||||
{name: "LF", lineEnding: "\n", final: "\n"},
|
||||
{name: "CRLF", lineEnding: "\r\n", final: "\r\n"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
source := "<code>**raw**" + tt.lineEnding + "Ⱥ</code>**你好。**S" + tt.final
|
||||
want := "<code>**raw**" + tt.lineEnding + "Ⱥ</code><b>你好。</b>S" + tt.final
|
||||
if got := preprocessCJKAdjacentMarkup(source); got != want {
|
||||
t.Fatalf("preprocessCJKAdjacentMarkup() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextProfileMatchesLarkOpenCLIContract(t *testing.T) {
|
||||
result, err := Parse(`<title>标题</title><p>一个苹果是 an apple。</p>`, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
profile := result.Profile
|
||||
if profile.WordCount != 10 || profile.CharCount != 15 {
|
||||
t.Fatalf("profile = %+v, want word_count=10 char_count=15", profile)
|
||||
}
|
||||
if profile.Breakdown.HanChars != 7 || profile.Breakdown.EnglishWords != 2 || profile.Breakdown.ChinesePunctuations != 1 {
|
||||
t.Fatalf("breakdown = %+v", profile.Breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextProfileMatchesAuthoringCounterCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
words int
|
||||
chars int
|
||||
blocks int
|
||||
english int
|
||||
numbers int
|
||||
han int
|
||||
listItems int
|
||||
}{
|
||||
{
|
||||
name: "english number and punctuation",
|
||||
source: `<p>Hello world 123.45。</p>`,
|
||||
words: 4, chars: 17, blocks: 1, english: 2, numbers: 1,
|
||||
},
|
||||
{
|
||||
name: "list and checkbox markers",
|
||||
source: `<ul><li>甲</li><li>two</li></ul><checkbox done="true">完成</checkbox>`,
|
||||
words: 7, chars: 9, blocks: 4, english: 1, han: 3, listItems: 2,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := Parse(tt.source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
profile := result.Profile
|
||||
if profile.WordCount != tt.words || profile.CharCount != tt.chars || profile.BlockCount != tt.blocks {
|
||||
t.Fatalf("profile = %+v, want words=%d chars=%d blocks=%d", profile, tt.words, tt.chars, tt.blocks)
|
||||
}
|
||||
if profile.Breakdown.EnglishWords != tt.english || profile.Breakdown.NumberWords != tt.numbers || profile.Breakdown.HanChars != tt.han {
|
||||
t.Fatalf("breakdown = %+v", profile.Breakdown)
|
||||
}
|
||||
if got := blockCountForTest(profile.Blocks, "li"); got != tt.listItems {
|
||||
t.Fatalf("li count = %d, want %d", got, tt.listItems)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextProfileUsesVisibleAttributeFallbacks(t *testing.T) {
|
||||
result, err := Parse(`<p text="Hello"/><p><span title="world"/></p><img href="https://example.com/image.png" caption="图"/>`, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
profile := result.Profile
|
||||
if profile.WordCount != 3 || profile.CharCount != 11 {
|
||||
t.Fatalf("profile = %+v, want word_count=3 char_count=11", profile)
|
||||
}
|
||||
if profile.Breakdown.EnglishWords != 2 || profile.Breakdown.HanChars != 1 {
|
||||
t.Fatalf("breakdown = %+v", profile.Breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsUnsafeXMLDeclarations(t *testing.T) {
|
||||
_, err := Parse(`<!DOCTYPE foo [<!ENTITY x "value">]><p>&x;</p>`, FormatXML)
|
||||
if err == nil || !strings.Contains(err.Error(), "DOCTYPE or ENTITY") {
|
||||
t.Fatalf("Parse() error = %v, want unsafe declaration rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsInvalidUTF8(t *testing.T) {
|
||||
_, err := Parse(string([]byte{'<', 'p', '>', 0xff, '<', '/', 'p', '>'}), FormatXML)
|
||||
if err == nil || !strings.Contains(err.Error(), "valid UTF-8") {
|
||||
t.Fatalf("Parse() error = %v, want UTF-8 rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsExcessiveNesting(t *testing.T) {
|
||||
source := strings.Repeat("<span>", MaxNestingDepth+1)
|
||||
_, err := Parse(source, FormatXML)
|
||||
if err == nil || !strings.Contains(err.Error(), "nesting exceeds") {
|
||||
t.Fatalf("Parse() error = %v, want nesting limit rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLRejectsNestedInvalidTagStarts(t *testing.T) {
|
||||
if _, err := Parse(`<<<<p>text</p>`, FormatXML); err == nil {
|
||||
t.Fatal("Parse() succeeded, want invalid XML token error")
|
||||
}
|
||||
}
|
||||
|
||||
func blockCountForTest(blocks []BlockShare, typ string) int {
|
||||
for _, block := range blocks {
|
||||
if block.Type == typ {
|
||||
return block.Count
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Profile describes LarkOpenCLI document structure and visible text without
|
||||
// requiring callers to inspect the full XML.
|
||||
type Profile struct {
|
||||
WordCount int `json:"word_count"`
|
||||
CharCount int `json:"char_count"`
|
||||
Breakdown TextBreakdown `json:"breakdown"`
|
||||
BlockCount int `json:"block_count"`
|
||||
Blocks []BlockShare `json:"blocks"`
|
||||
}
|
||||
|
||||
// BlockShare reports one LarkOpenCLI block type's count and share. Structural
|
||||
// and inline-only tags are intentionally excluded.
|
||||
type BlockShare struct {
|
||||
Type string `json:"type"`
|
||||
Count int `json:"count"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
}
|
||||
|
||||
// TextProfile is the internal result of the LarkOpenCLI semantic counter.
|
||||
type TextProfile struct {
|
||||
WordCount int `json:"word_count"`
|
||||
CharCount int `json:"char_count"`
|
||||
Breakdown TextBreakdown `json:"breakdown"`
|
||||
}
|
||||
|
||||
type TextBreakdown struct {
|
||||
HanChars int `json:"han_chars"`
|
||||
EnglishWords int `json:"english_words"`
|
||||
NumberWords int `json:"number_words"`
|
||||
ChinesePunctuations int `json:"chinese_punctuations"`
|
||||
EnglishLetters int `json:"english_letters"`
|
||||
Digits int `json:"digits"`
|
||||
EnglishPunctuations int `json:"english_punctuations"`
|
||||
SymbolWords int `json:"symbol_words"`
|
||||
SymbolChars int `json:"symbol_chars"`
|
||||
}
|
||||
|
||||
// Parse validates XML or converts Markdown to DocxXML, then builds its
|
||||
// structure and visible-text profile.
|
||||
func Parse(source string, format Format) (ParseResult, error) {
|
||||
var (
|
||||
nodes []*Node
|
||||
outputXML string
|
||||
err error
|
||||
)
|
||||
switch format {
|
||||
case FormatXML:
|
||||
nodes, err = parseXML(source)
|
||||
outputXML = source
|
||||
case FormatMarkdown:
|
||||
nodes, err = parseMarkdown(source)
|
||||
default:
|
||||
return ParseResult{}, fmt.Errorf("unsupported input format %q", format)
|
||||
}
|
||||
if err != nil {
|
||||
return ParseResult{}, err
|
||||
}
|
||||
if err := validateStructure(nodes); err != nil {
|
||||
return ParseResult{}, err
|
||||
}
|
||||
if format == FormatMarkdown {
|
||||
outputXML = renderNodes(nodes)
|
||||
}
|
||||
|
||||
return ParseResult{
|
||||
Format: format,
|
||||
XML: outputXML,
|
||||
Profile: buildProfile(nodes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseAuto detects XML versus Markdown from the content and returns only the
|
||||
// document profile. XML-like input is parsed strictly; all other input is
|
||||
// interpreted as Markdown.
|
||||
func ParseAuto(source string) (Profile, error) {
|
||||
result, err := Parse(source, detectFormat(source))
|
||||
if err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
return result.Profile, nil
|
||||
}
|
||||
|
||||
// MarkdownToXML converts Markdown to canonical LarkOpenCLI XML.
|
||||
func MarkdownToXML(source string) (string, error) {
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.XML, nil
|
||||
}
|
||||
|
||||
func detectFormat(source string) Format {
|
||||
trimmed := strings.TrimSpace(strings.TrimPrefix(source, "\uFEFF"))
|
||||
if strings.HasPrefix(trimmed, "<") {
|
||||
return FormatXML
|
||||
}
|
||||
return FormatMarkdown
|
||||
}
|
||||
|
||||
func validateStructure(nodes []*Node) error {
|
||||
type frame struct {
|
||||
node *Node
|
||||
exit bool
|
||||
}
|
||||
frames := make([]frame, 0, len(nodes))
|
||||
for i := len(nodes) - 1; i >= 0; i-- {
|
||||
frames = append(frames, frame{node: nodes[i]})
|
||||
}
|
||||
ancestors := map[string]int{}
|
||||
depth := 0
|
||||
for len(frames) > 0 {
|
||||
current := frames[len(frames)-1]
|
||||
frames = frames[:len(frames)-1]
|
||||
node := current.node
|
||||
if node == nil || node.typ != nodeElement {
|
||||
continue
|
||||
}
|
||||
if current.exit {
|
||||
ancestors[node.tag]--
|
||||
depth--
|
||||
continue
|
||||
}
|
||||
if depth >= MaxNestingDepth {
|
||||
return fmt.Errorf("document nesting exceeds limit %d at <%s>", MaxNestingDepth, node.tag)
|
||||
}
|
||||
if err := validateRequiredAttributes(node); err != nil {
|
||||
return err
|
||||
}
|
||||
if required := requiredAncestorTags[node.tag]; len(required) > 0 {
|
||||
matched := false
|
||||
for tag := range required {
|
||||
if ancestors[tag] > 0 {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
allowed := make([]string, 0, len(required))
|
||||
for tag := range required {
|
||||
allowed = append(allowed, tag)
|
||||
}
|
||||
sort.Strings(allowed)
|
||||
return fmt.Errorf("LarkOpenCLI tag <%s> requires an ancestor in [%s]", node.tag, strings.Join(allowed, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
ancestors[node.tag]++
|
||||
depth++
|
||||
frames = append(frames, frame{node: node, exit: true})
|
||||
for i := len(node.children) - 1; i >= 0; i-- {
|
||||
frames = append(frames, frame{node: node.children[i]})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRequiredAttributes(node *Node) error {
|
||||
for _, attr := range requiredAttributes[node.tag] {
|
||||
if strings.TrimSpace(node.attrs[attr]) == "" {
|
||||
return fmt.Errorf("LarkOpenCLI tag <%s> requires attribute %q", node.tag, attr)
|
||||
}
|
||||
}
|
||||
for _, alternatives := range requiredAnyAttributes[node.tag] {
|
||||
matched := false
|
||||
for _, attr := range alternatives {
|
||||
if strings.TrimSpace(node.attrs[attr]) != "" {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("LarkOpenCLI tag <%s> requires one of attributes [%s]", node.tag, strings.Join(alternatives, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildProfile(nodes []*Node) Profile {
|
||||
counts := map[string]int{}
|
||||
total := 0
|
||||
var walk func(*Node)
|
||||
walk = func(node *Node) {
|
||||
if node == nil || node.typ != nodeElement {
|
||||
return
|
||||
}
|
||||
layout := layoutOf(node.tag)
|
||||
isBlock := layout == layoutBlock || layout == layoutDual && node.parent == nil
|
||||
if isBlock {
|
||||
counts[node.tag]++
|
||||
total++
|
||||
}
|
||||
for _, child := range node.children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
for _, node := range nodes {
|
||||
walk(node)
|
||||
}
|
||||
|
||||
distribution := make([]BlockShare, 0, len(counts))
|
||||
for typ, count := range counts {
|
||||
ratio := 0.0
|
||||
if total > 0 {
|
||||
ratio = math.Round(float64(count)/float64(total)*1_000_000) / 1_000_000
|
||||
}
|
||||
distribution = append(distribution, BlockShare{Type: typ, Count: count, Ratio: ratio})
|
||||
}
|
||||
sort.Slice(distribution, func(i, j int) bool {
|
||||
if distribution[i].Count != distribution[j].Count {
|
||||
return distribution[i].Count > distribution[j].Count
|
||||
}
|
||||
return distribution[i].Type < distribution[j].Type
|
||||
})
|
||||
segments := extractSegments(nodes)
|
||||
stats := newTextCounter().countSegments(segments)
|
||||
return Profile{
|
||||
WordCount: stats.WordCount,
|
||||
CharCount: stats.CharCount,
|
||||
Breakdown: stats.Breakdown,
|
||||
BlockCount: total,
|
||||
Blocks: distribution,
|
||||
}
|
||||
}
|
||||
|
||||
type segmentKind uint8
|
||||
|
||||
const (
|
||||
segmentText segmentKind = iota
|
||||
segmentMarker
|
||||
segmentCode
|
||||
)
|
||||
|
||||
type textSegment struct {
|
||||
text string
|
||||
kind segmentKind
|
||||
}
|
||||
|
||||
var ignoredResourceTags = map[string]bool{
|
||||
"whiteboard": true, "sheet": true, "source": true, "chat_card": true,
|
||||
"base_refer": true, "bitable": true, "synced_reference": true,
|
||||
"poll": true, "isv": true, "mindnote": true, "sub-page-list": true,
|
||||
"okr": true, "html5-block": true,
|
||||
}
|
||||
|
||||
var ignoredInlineTags = map[string]bool{
|
||||
"button": true, "cite": true, "latex": true, "bookmark": true,
|
||||
}
|
||||
|
||||
func extractSegments(nodes []*Node) []textSegment {
|
||||
var segments []textSegment
|
||||
for _, node := range nodes {
|
||||
extractNodeSegments(node, &segments)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
func extractNodeSegments(node *Node, segments *[]textSegment) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.typ == nodeText {
|
||||
if strings.TrimSpace(node.text) != "" {
|
||||
*segments = append(*segments, textSegment{text: node.text})
|
||||
}
|
||||
return
|
||||
}
|
||||
if ignoredInlineTags[node.tag] || ignoredResourceTags[node.tag] {
|
||||
return
|
||||
}
|
||||
if node.tag == "task" {
|
||||
return
|
||||
}
|
||||
if node.tag == "synced-source" && len(node.children) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
switch node.tag {
|
||||
case "ul", "ol":
|
||||
sequence := 1
|
||||
for _, child := range node.children {
|
||||
if child.typ == nodeElement && child.tag == "li" {
|
||||
if node.tag == "ul" {
|
||||
*segments = append(*segments, textSegment{text: "•", kind: segmentMarker})
|
||||
} else {
|
||||
marker := sequence
|
||||
if raw := child.attrs["seq"]; raw != "" {
|
||||
if _, err := fmt.Sscanf(raw, "%d", &marker); err == nil {
|
||||
sequence = marker
|
||||
}
|
||||
}
|
||||
*segments = append(*segments, textSegment{text: fmt.Sprintf("%d.", marker)})
|
||||
sequence++
|
||||
}
|
||||
}
|
||||
extractNodeSegments(child, segments)
|
||||
}
|
||||
return
|
||||
case "checkbox":
|
||||
marker := "☐"
|
||||
if node.attrs["done"] == "true" {
|
||||
marker = "☑"
|
||||
}
|
||||
*segments = append(*segments, textSegment{text: marker, kind: segmentMarker})
|
||||
}
|
||||
|
||||
kind := segmentText
|
||||
if node.tag == "pre" || node.tag == "code" && (node.parent == nil || node.parent.tag != "p") {
|
||||
kind = segmentCode
|
||||
}
|
||||
text := visibleInlineText(node)
|
||||
if strings.TrimSpace(text) == "" && !hasBlockChildren(node) {
|
||||
if node.tag == "img" {
|
||||
text = node.attrs["caption"]
|
||||
} else {
|
||||
text = firstNonEmpty(node.attrs["text"], node.attrs["name"], node.attrs["title"], node.attrs["alt"], node.attrs["caption"])
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(text) != "" {
|
||||
*segments = append(*segments, textSegment{text: text, kind: kind})
|
||||
}
|
||||
|
||||
for _, child := range node.children {
|
||||
if child.typ != nodeElement || isInlineForExtraction(child.tag) {
|
||||
continue
|
||||
}
|
||||
extractNodeSegments(child, segments)
|
||||
}
|
||||
}
|
||||
|
||||
func visibleInlineText(node *Node) string {
|
||||
var out strings.Builder
|
||||
var walk func(*Node)
|
||||
walk = func(current *Node) {
|
||||
if current.typ == nodeText {
|
||||
out.WriteString(current.text)
|
||||
return
|
||||
}
|
||||
if current != node && !isInlineForExtraction(current.tag) {
|
||||
return
|
||||
}
|
||||
if ignoredInlineTags[current.tag] {
|
||||
return
|
||||
}
|
||||
if current.tag == "br" {
|
||||
out.WriteByte('\n')
|
||||
return
|
||||
}
|
||||
if current != node {
|
||||
if display := firstNonEmpty(current.attrs["text"], current.attrs["name"], current.attrs["title"], current.attrs["alt"]); display != "" {
|
||||
out.WriteString(display)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, child := range current.children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
for _, child := range node.children {
|
||||
walk(child)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func hasBlockChildren(node *Node) bool {
|
||||
for _, child := range node.children {
|
||||
if child.typ == nodeElement && !isInlineForExtraction(child.tag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isInlineForExtraction(tag string) bool {
|
||||
layout := layoutOf(tag)
|
||||
return layout == layoutInline || layout == layoutDual
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type tagLayout string
|
||||
|
||||
const (
|
||||
layoutBlock tagLayout = "block"
|
||||
layoutInline tagLayout = "inline"
|
||||
layoutDual tagLayout = "dual"
|
||||
layoutStructural tagLayout = "structural"
|
||||
layoutCommand tagLayout = "command"
|
||||
)
|
||||
|
||||
type tagSpec struct {
|
||||
canonical string
|
||||
layout tagLayout
|
||||
}
|
||||
|
||||
var tagSpecs = map[string]tagSpec{}
|
||||
|
||||
// tagAliases mirrors the public compatibility aliases declared by the
|
||||
// LarkOpenCLI SDK. Parsing keeps the caller's XML unchanged; aliases are only
|
||||
// canonicalized in the in-memory tree used for profiling and Markdown output.
|
||||
var tagAliases = map[string]string{
|
||||
"strong": "b",
|
||||
"text": "span",
|
||||
"equation": "latex",
|
||||
"lark-table": "table",
|
||||
"lark-tr": "tr",
|
||||
"lark-td": "td",
|
||||
"image": "img",
|
||||
"reference-synced": "synced_reference",
|
||||
"source-synced": "synced-source",
|
||||
"at": "cite",
|
||||
"chat-card": "chat_card",
|
||||
"folder_manager": "folder-manager",
|
||||
}
|
||||
|
||||
type attributeAliasRule struct {
|
||||
canonical string
|
||||
transform func(string) (string, bool)
|
||||
}
|
||||
|
||||
var commonAttributeAliases = map[string]attributeAliasRule{
|
||||
"color": {canonical: "text-color"},
|
||||
"textcolor": {canonical: "text-color"},
|
||||
"text_color": {canonical: "text-color"},
|
||||
"bgcolor": {canonical: "background-color"},
|
||||
"background_color": {canonical: "background-color"},
|
||||
}
|
||||
|
||||
var tagAttributeAliases = map[string]map[string]attributeAliasRule{
|
||||
"img": {
|
||||
"url": {canonical: "href"},
|
||||
"file_key": {canonical: "img_key"},
|
||||
},
|
||||
"callout": {
|
||||
"color": {canonical: "background-color"},
|
||||
"icon": {canonical: "emoji"},
|
||||
},
|
||||
"column": {
|
||||
"width": {canonical: "width-ratio", transform: normalizeWidthRatio},
|
||||
},
|
||||
"chat_card": {
|
||||
"id": {canonical: "chat-id", transform: requireChatID},
|
||||
},
|
||||
"cite": {
|
||||
"user_id": {canonical: "user-id"},
|
||||
},
|
||||
}
|
||||
|
||||
var rawTagAttributeAliases = map[string]map[string]attributeAliasRule{
|
||||
"at": {
|
||||
"id": {canonical: "user-id"},
|
||||
"user_id": {canonical: "user-id"},
|
||||
},
|
||||
}
|
||||
|
||||
var requiredAttributes = map[string][]string{
|
||||
"task": {"task-id"},
|
||||
}
|
||||
|
||||
var requiredAnyAttributes = map[string][][]string{
|
||||
"img": {{"src", "img_key", "href"}},
|
||||
"whiteboard": {{"token", "type"}},
|
||||
"chat_card": {{"token", "chat-id"}},
|
||||
"bookmark": {{"href", "name"}},
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerTags(layoutBlock,
|
||||
"title", "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "h9", "p",
|
||||
"div", "ul", "ol", "li", "blockquote", "grid", "column", "table", "thead",
|
||||
"tbody", "tfoot", "tr", "hr", "pre", "img", "source", "bitable", "sheet",
|
||||
"mindnote", "whiteboard", "base_refer", "synced_reference", "isv", "html5-block",
|
||||
"view", "synced-source", "readonly-block", "figure", "callout", "checkbox",
|
||||
"chat_card", "okr", "okr-objective", "okr-key-result", "okr-progress", "poll",
|
||||
"agenda", "folder-manager", "sub-page-list", "wiki_catalog", "wiki_recent_update",
|
||||
"chart-embedded", "chart-refer-host-perm", "chart_embedded", "chart_refer_host_perm",
|
||||
"bookmark", "task", "vc-tabs", "vc-summary-tab", "vc-transcribe-tab", "append",
|
||||
)
|
||||
registerTags(layoutInline, "b", "em", "u", "del", "i", "span", "br", "inline-file", "mention-date", "cite", "button", "time", "a")
|
||||
registerTags(layoutDual, "latex", "code")
|
||||
registerTags(layoutStructural, "th", "td", "colgroup", "col", "sub-page")
|
||||
registerTags(layoutCommand,
|
||||
"comment", "block_delete", "str_delete", "str_replace", "block_replace", "block_insert",
|
||||
"block_move", "block_copy_insert_after", "src_block_ids", "create", "answer", "response",
|
||||
"identifier", "genre", "anchor", "type", "revision", "pattern", "replacement",
|
||||
"replace_content", "action", "content", "parameter", "generation", "block_id",
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
func registerTags(layout tagLayout, tags ...string) {
|
||||
for _, tag := range tags {
|
||||
tagSpecs[tag] = tagSpec{canonical: tag, layout: layout}
|
||||
}
|
||||
}
|
||||
|
||||
func lookupTag(raw string) (tagSpec, bool) {
|
||||
key := strings.ToLower(strings.TrimSpace(raw))
|
||||
if canonical, ok := tagAliases[key]; ok {
|
||||
key = canonical
|
||||
}
|
||||
spec, ok := tagSpecs[key]
|
||||
if !ok {
|
||||
return tagSpec{}, false
|
||||
}
|
||||
return spec, true
|
||||
}
|
||||
|
||||
func layoutOf(tag string) tagLayout {
|
||||
spec, ok := lookupTag(tag)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return spec.layout
|
||||
}
|
||||
|
||||
var voidTags = map[string]bool{
|
||||
"br": true,
|
||||
"col": true,
|
||||
"hr": true,
|
||||
"img": true,
|
||||
"source": true,
|
||||
"sub-page": true,
|
||||
}
|
||||
|
||||
func isVoidTag(tag string) bool { return voidTags[tag] }
|
||||
|
||||
var preserveSpaceTags = map[string]bool{
|
||||
"title": true, "h1": true, "h2": true, "h3": true, "h4": true,
|
||||
"h5": true, "h6": true, "h7": true, "h8": true, "h9": true,
|
||||
"p": true, "i": true, "b": true, "em": true, "u": true, "del": true,
|
||||
"code": true, "li": true, "a": true, "span": true,
|
||||
}
|
||||
|
||||
var strictPhrasingTags = map[string]bool{
|
||||
"title": true, "span": true, "b": true, "em": true,
|
||||
"u": true, "del": true, "a": true,
|
||||
}
|
||||
|
||||
var autoCloseTags = map[string]map[string]bool{
|
||||
"li": {"li": true},
|
||||
"tr": {"tr": true},
|
||||
"td": {"td": true, "th": true, "tr": true, "tbody": true, "tfoot": true},
|
||||
"th": {"th": true, "td": true, "tr": true, "tbody": true, "tfoot": true},
|
||||
"tbody": {"tbody": true, "tfoot": true},
|
||||
"thead": {"tbody": true, "tfoot": true},
|
||||
"column": {"column": true},
|
||||
}
|
||||
|
||||
var requiredAncestorTags = map[string]map[string]bool{
|
||||
"column": {"grid": true},
|
||||
"thead": {"table": true},
|
||||
"tbody": {"table": true},
|
||||
"tfoot": {"table": true},
|
||||
"tr": {"table": true, "thead": true, "tbody": true, "tfoot": true},
|
||||
"th": {"tr": true},
|
||||
"td": {"tr": true},
|
||||
"colgroup": {"table": true},
|
||||
"col": {"table": true, "colgroup": true},
|
||||
"okr-objective": {"okr": true},
|
||||
"okr-key-result": {"okr": true, "okr-objective": true},
|
||||
"okr-progress": {"okr-objective": true, "okr-key-result": true},
|
||||
"sub-page": {"sub-page-list": true},
|
||||
}
|
||||
|
||||
func shouldAutoClose(openTag, nextTag string) bool {
|
||||
if strictPhrasingTags[openTag] && layoutOf(nextTag) == layoutBlock {
|
||||
return true
|
||||
}
|
||||
return autoCloseTags[openTag] != nil && autoCloseTags[openTag][nextTag]
|
||||
}
|
||||
|
||||
func normalizeAttributes(rawTag, canonical string, attrs map[string]string) map[string]string {
|
||||
rules := make(map[string]attributeAliasRule, len(commonAttributeAliases)+4)
|
||||
for alias, rule := range commonAttributeAliases {
|
||||
rules[alias] = rule
|
||||
}
|
||||
for alias, rule := range tagAttributeAliases[canonical] {
|
||||
rules[alias] = rule
|
||||
}
|
||||
rawKey := strings.ToLower(strings.TrimSpace(rawTag))
|
||||
for alias, rule := range rawTagAttributeAliases[rawKey] {
|
||||
rules[alias] = rule
|
||||
}
|
||||
|
||||
aliases := make([]string, 0, len(rules))
|
||||
for alias := range rules {
|
||||
aliases = append(aliases, alias)
|
||||
}
|
||||
sort.Strings(aliases)
|
||||
for _, alias := range aliases {
|
||||
value, exists := attrs[alias]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
rule := rules[alias]
|
||||
if rule.transform != nil {
|
||||
var ok bool
|
||||
value, ok = rule.transform(value)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if canonicalValue, exists := attrs[rule.canonical]; !exists || strings.TrimSpace(canonicalValue) == "" {
|
||||
if attrs == nil {
|
||||
attrs = map[string]string{}
|
||||
}
|
||||
attrs[rule.canonical] = value
|
||||
}
|
||||
delete(attrs, alias)
|
||||
}
|
||||
|
||||
if rawKey == "at" {
|
||||
if attrs == nil {
|
||||
attrs = map[string]string{}
|
||||
}
|
||||
attrs["type"] = "user"
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func normalizeWidthRatio(value string) (string, bool) {
|
||||
trimmed := strings.TrimSuffix(strings.TrimSpace(value), "%")
|
||||
if trimmed == "" {
|
||||
return value, false
|
||||
}
|
||||
width, err := strconv.ParseFloat(trimmed, 64)
|
||||
if err != nil {
|
||||
return value, false
|
||||
}
|
||||
return strconv.FormatFloat(width/100, 'f', 6, 64), true
|
||||
}
|
||||
|
||||
func requireChatID(value string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
return trimmed, strings.HasPrefix(trimmed, "oc_")
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
// This file implements the LarkOpenCLI document text-counting contract.
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/width"
|
||||
)
|
||||
|
||||
const chinesePunctuation = ",。!?;:、()《》〈〉“”‘’【】「」『』〔〕…—~·¥"
|
||||
const englishPunctuation = `!"#$%&'()*+,-./:;<=>?@[\]^_` + "`" + `{|}~`
|
||||
|
||||
var (
|
||||
urlToken = regexp.MustCompile(`^https?://[!-~]+`)
|
||||
asciiCompoundToken = regexp.MustCompile(`^[A-Za-z0-9]+(?:[._/@:-][A-Za-z0-9]+)+`)
|
||||
)
|
||||
|
||||
type lexemeKind uint8
|
||||
|
||||
const (
|
||||
lexemeNone lexemeKind = iota
|
||||
lexemeEnglish
|
||||
lexemeNumber
|
||||
)
|
||||
|
||||
type textCounter struct {
|
||||
stats TextProfile
|
||||
lexeme lexemeKind
|
||||
lexemeHasDigit bool
|
||||
symbolRunLength int
|
||||
atBoundary bool
|
||||
}
|
||||
|
||||
func newTextCounter() *textCounter {
|
||||
return &textCounter{atBoundary: true}
|
||||
}
|
||||
|
||||
func (c *textCounter) countSegments(segments []textSegment) TextProfile {
|
||||
for _, segment := range segments {
|
||||
c.endUnit()
|
||||
c.atBoundary = true
|
||||
switch segment.kind {
|
||||
case segmentMarker:
|
||||
c.writeMarker(segment.text)
|
||||
case segmentCode:
|
||||
c.writeCode(segment.text)
|
||||
default:
|
||||
c.write(segment.text)
|
||||
}
|
||||
c.endUnit()
|
||||
c.atBoundary = true
|
||||
}
|
||||
c.endUnit()
|
||||
return c.stats
|
||||
}
|
||||
|
||||
func (c *textCounter) write(value string) {
|
||||
for offset := 0; offset < len(value); {
|
||||
if token := matchASCIICompound(value[offset:]); token != "" {
|
||||
c.writeASCIICompound(token)
|
||||
offset += len(token)
|
||||
continue
|
||||
}
|
||||
r, size := utf8.DecodeRuneInString(value[offset:])
|
||||
if r == '/' && isVisibleHanSeparator(value, offset, size) {
|
||||
c.endUnit()
|
||||
c.stats.Breakdown.EnglishPunctuations++
|
||||
c.stats.Breakdown.SymbolWords++
|
||||
c.stats.WordCount++
|
||||
c.stats.CharCount++
|
||||
c.atBoundary = false
|
||||
offset += size
|
||||
continue
|
||||
}
|
||||
c.writeRune(r)
|
||||
offset += size
|
||||
}
|
||||
}
|
||||
|
||||
func (c *textCounter) writeMarker(value string) {
|
||||
for _, r := range value {
|
||||
if unicode.IsSpace(r) {
|
||||
continue
|
||||
}
|
||||
c.endUnit()
|
||||
c.stats.WordCount++
|
||||
c.stats.CharCount++
|
||||
c.atBoundary = false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *textCounter) writeCode(value string) {
|
||||
for _, r := range value {
|
||||
c.writeCodeRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *textCounter) writeCodeRune(r rune) {
|
||||
if unicode.IsSpace(r) {
|
||||
c.endUnit()
|
||||
c.atBoundary = true
|
||||
return
|
||||
}
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.HanChars++
|
||||
c.stats.WordCount++
|
||||
c.stats.CharCount++
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isASCIILetterRune(r) {
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.EnglishLetters++
|
||||
c.stats.CharCount++
|
||||
if c.lexeme == lexemeNone || c.lexeme == lexemeNumber {
|
||||
c.lexeme = lexemeEnglish
|
||||
}
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isASCIIDigitRune(r) {
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.Digits++
|
||||
c.stats.CharCount++
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isChinesePunctuation(r) {
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.ChinesePunctuations++
|
||||
c.stats.WordCount++
|
||||
c.stats.CharCount++
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isEnglishPunctuation(r) {
|
||||
keepsLexeme := c.lexeme == lexemeEnglish && (r == '\'' || r == '-')
|
||||
if !keepsLexeme {
|
||||
hadLexeme := c.lexeme != lexemeNone
|
||||
c.endLexeme()
|
||||
if !hadLexeme && (c.symbolRunLength > 0 || c.atBoundary) {
|
||||
c.symbolRunLength++
|
||||
}
|
||||
}
|
||||
c.stats.Breakdown.EnglishPunctuations++
|
||||
c.stats.CharCount++
|
||||
if keepsLexeme {
|
||||
c.atBoundary = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if unicode.Is(unicode.Symbol, r) {
|
||||
c.writeSymbol(r)
|
||||
return
|
||||
}
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(false)
|
||||
c.atBoundary = false
|
||||
}
|
||||
|
||||
func (c *textCounter) writeRune(r rune) {
|
||||
if unicode.IsSpace(r) {
|
||||
c.endUnit()
|
||||
c.atBoundary = true
|
||||
return
|
||||
}
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.HanChars++
|
||||
c.stats.WordCount++
|
||||
c.stats.CharCount++
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isASCIILetterRune(r) {
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.EnglishLetters++
|
||||
c.stats.CharCount++
|
||||
if c.lexeme == lexemeNone || c.lexeme == lexemeNumber {
|
||||
c.lexeme = lexemeEnglish
|
||||
}
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isASCIIDigitRune(r) {
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.Digits++
|
||||
c.stats.CharCount++
|
||||
c.lexemeHasDigit = true
|
||||
if c.lexeme == lexemeNone {
|
||||
c.lexeme = lexemeNumber
|
||||
}
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isChinesePunctuation(r) {
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.ChinesePunctuations++
|
||||
c.stats.WordCount++
|
||||
c.stats.CharCount++
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isEnglishPunctuation(r) {
|
||||
keepsLexeme := c.lexeme == lexemeEnglish && (r == '\'' || r == '-' || c.lexemeHasDigit && r == '.') ||
|
||||
c.lexeme == lexemeNumber && (r == '.' || r == ',' || r == '-')
|
||||
if !keepsLexeme {
|
||||
hadLexeme := c.lexeme != lexemeNone
|
||||
c.endLexeme()
|
||||
if !hadLexeme && (c.symbolRunLength > 0 || c.atBoundary) {
|
||||
c.symbolRunLength++
|
||||
}
|
||||
}
|
||||
c.stats.Breakdown.EnglishPunctuations++
|
||||
c.stats.CharCount++
|
||||
if keepsLexeme {
|
||||
c.atBoundary = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if unicode.Is(unicode.Symbol, r) {
|
||||
c.writeSymbol(r)
|
||||
return
|
||||
}
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(false)
|
||||
c.atBoundary = false
|
||||
}
|
||||
|
||||
func matchASCIICompound(value string) string {
|
||||
if match := urlToken.FindString(value); match != "" {
|
||||
return match
|
||||
}
|
||||
match := asciiCompoundToken.FindString(value)
|
||||
if match == "" || !strings.ContainsAny(match, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
||||
return ""
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
func (c *textCounter) writeASCIICompound(token string) {
|
||||
c.endUnit()
|
||||
c.stats.Breakdown.EnglishWords++
|
||||
c.stats.WordCount++
|
||||
for _, r := range token {
|
||||
switch {
|
||||
case isASCIILetterRune(r):
|
||||
c.stats.Breakdown.EnglishLetters++
|
||||
c.stats.CharCount++
|
||||
case isASCIIDigitRune(r):
|
||||
c.stats.Breakdown.Digits++
|
||||
c.stats.CharCount++
|
||||
case isEnglishPunctuation(r):
|
||||
c.stats.Breakdown.EnglishPunctuations++
|
||||
c.stats.CharCount++
|
||||
}
|
||||
}
|
||||
c.atBoundary = false
|
||||
}
|
||||
|
||||
func (c *textCounter) writeSymbol(r rune) {
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(false)
|
||||
units := utf16Units(r)
|
||||
c.stats.Breakdown.SymbolWords++
|
||||
c.stats.Breakdown.SymbolChars += units
|
||||
c.stats.WordCount++
|
||||
c.stats.CharCount += units
|
||||
c.atBoundary = false
|
||||
}
|
||||
|
||||
func (c *textCounter) endUnit() {
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(true)
|
||||
}
|
||||
|
||||
func (c *textCounter) endLexeme() {
|
||||
switch c.lexeme {
|
||||
case lexemeEnglish:
|
||||
c.stats.Breakdown.EnglishWords++
|
||||
c.stats.WordCount++
|
||||
case lexemeNumber:
|
||||
c.stats.Breakdown.NumberWords++
|
||||
c.stats.WordCount++
|
||||
}
|
||||
c.lexeme = lexemeNone
|
||||
c.lexemeHasDigit = false
|
||||
}
|
||||
|
||||
func (c *textCounter) endSymbolRun(countWord bool) {
|
||||
if c.symbolRunLength > 0 && countWord {
|
||||
c.stats.Breakdown.SymbolWords++
|
||||
c.stats.WordCount++
|
||||
}
|
||||
if c.symbolRunLength > 0 {
|
||||
c.atBoundary = false
|
||||
}
|
||||
c.symbolRunLength = 0
|
||||
}
|
||||
|
||||
func isVisibleHanSeparator(value string, offset, size int) bool {
|
||||
if offset == 0 || offset+size >= len(value) {
|
||||
return false
|
||||
}
|
||||
previous, _ := utf8.DecodeLastRuneInString(value[:offset])
|
||||
next, _ := utf8.DecodeRuneInString(value[offset+size:])
|
||||
return unicode.Is(unicode.Han, previous) && unicode.Is(unicode.Han, next)
|
||||
}
|
||||
|
||||
func isASCIILetterRune(r rune) bool { return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' }
|
||||
func isASCIIDigitRune(r rune) bool { return r >= '0' && r <= '9' }
|
||||
|
||||
func isChinesePunctuation(r rune) bool {
|
||||
if strings.ContainsRune(chinesePunctuation, r) {
|
||||
return true
|
||||
}
|
||||
kind := width.LookupRune(r).Kind()
|
||||
return unicode.Is(unicode.Punct, r) && (kind == width.EastAsianWide || kind == width.EastAsianFullwidth)
|
||||
}
|
||||
|
||||
func isEnglishPunctuation(r rune) bool {
|
||||
return r < utf8.RuneSelf && strings.ContainsRune(englishPunctuation, r)
|
||||
}
|
||||
|
||||
func utf16Units(r rune) int {
|
||||
if r > 0xffff {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -1,572 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxInputBytes = 20_000_000
|
||||
MaxNestingDepth = 1024
|
||||
)
|
||||
|
||||
var forbiddenXMLDeclaration = regexp.MustCompile(`(?i)<!\s*(?:DOCTYPE|ENTITY)\b`)
|
||||
|
||||
func validateSource(source string) error {
|
||||
if len(source) > MaxInputBytes {
|
||||
return fmt.Errorf("input is too large (%d bytes, limit %d)", len(source), MaxInputBytes)
|
||||
}
|
||||
if forbiddenXMLDeclaration.MatchString(source) {
|
||||
return fmt.Errorf("XML input must not contain DOCTYPE or ENTITY declarations")
|
||||
}
|
||||
if !utf8.ValidString(source) {
|
||||
return fmt.Errorf("input must be valid UTF-8")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseXML(source string) ([]*Node, error) {
|
||||
if err := validateSource(source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source = strings.TrimPrefix(source, "\uFEFF")
|
||||
|
||||
root := newElement("__fragment__", nil)
|
||||
stack := []*Node{root}
|
||||
for i := 0; i < len(source); {
|
||||
lt := strings.IndexByte(source[i:], '<')
|
||||
if lt < 0 {
|
||||
if err := validateXMLText(source[i:], i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendText(stack[len(stack)-1], source[i:])
|
||||
break
|
||||
}
|
||||
lt += i
|
||||
if err := validateXMLText(source[i:lt], i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendText(stack[len(stack)-1], source[i:lt])
|
||||
|
||||
token, end, state := scanXMLToken(source, lt)
|
||||
switch state {
|
||||
case tokenComment, tokenProcessingInstruction:
|
||||
i = end
|
||||
continue
|
||||
case tokenCDATA:
|
||||
appendTextValue(stack[len(stack)-1], token.text)
|
||||
i = end
|
||||
continue
|
||||
case tokenInvalid:
|
||||
return nil, fmt.Errorf("invalid XML token at byte %d", lt)
|
||||
case tokenIncomplete:
|
||||
return nil, fmt.Errorf("unterminated XML tag at byte %d", lt)
|
||||
}
|
||||
|
||||
spec, allowed := lookupTag(token.name)
|
||||
if !allowed {
|
||||
return nil, fmt.Errorf("unsupported LarkOpenCLI tag <%s> at byte %d", token.name, lt)
|
||||
}
|
||||
canonical := spec.canonical
|
||||
if token.spacingNormalized {
|
||||
return nil, fmt.Errorf("invalid whitespace in XML tag <%s> at byte %d", token.name, lt)
|
||||
}
|
||||
|
||||
if token.closing {
|
||||
if isVoidTag(canonical) {
|
||||
return nil, fmt.Errorf("void tag <%s/> must not have a closing tag", canonical)
|
||||
}
|
||||
if len(stack) == 1 {
|
||||
return nil, fmt.Errorf("unexpected closing tag </%s> at byte %d", canonical, lt)
|
||||
}
|
||||
open := stack[len(stack)-1].tag
|
||||
if open != canonical {
|
||||
return nil, fmt.Errorf("mismatched closing tag </%s> at byte %d; expected </%s>", canonical, lt, open)
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
if len(stack) > 1 && shouldAutoClose(stack[len(stack)-1].tag, canonical) {
|
||||
return nil, fmt.Errorf("invalid <%s> inside <%s> at byte %d", canonical, stack[len(stack)-1].tag, lt)
|
||||
}
|
||||
attrs := normalizeAttributes(token.name, canonical, token.attrs)
|
||||
node := newElement(canonical, attrs)
|
||||
stack[len(stack)-1].addChild(node)
|
||||
if !token.selfClosing && !isVoidTag(canonical) {
|
||||
if len(stack) > MaxNestingDepth {
|
||||
return nil, fmt.Errorf("XML nesting exceeds limit %d at byte %d", MaxNestingDepth, lt)
|
||||
}
|
||||
stack = append(stack, node)
|
||||
}
|
||||
i = end
|
||||
}
|
||||
|
||||
if len(stack) > 1 {
|
||||
return nil, fmt.Errorf("missing closing tag </%s> at end of input", stack[len(stack)-1].tag)
|
||||
}
|
||||
normalizeParsedLineBreaks(root.children, false, false)
|
||||
for _, child := range root.children {
|
||||
child.parent = nil
|
||||
}
|
||||
return root.children, nil
|
||||
}
|
||||
|
||||
// normalizeParsedLineBreaks removes formatting newlines from ordinary XML,
|
||||
// while source-bearing code/whiteboard blocks keep semantic
|
||||
// line breaks as explicit <br/> nodes. str_replace pattern/replacement payloads
|
||||
// retain raw newlines because their string matching semantics depend on them.
|
||||
func normalizeParsedLineBreaks(nodes []*Node, sourceBlock, stringMutation bool) {
|
||||
for _, node := range nodes {
|
||||
if node == nil || node.typ != nodeElement {
|
||||
continue
|
||||
}
|
||||
nextSourceBlock := sourceBlock || node.tag == "code" || node.tag == "whiteboard"
|
||||
nextStringMutation := stringMutation || node.tag == "str_replace"
|
||||
preserveRaw := nextStringMutation && (node.tag == "pattern" || node.tag == "replacement")
|
||||
if node.tag == "code" || node.tag == "whiteboard" {
|
||||
trimSourceBlockBoundaryNewlines(node.children)
|
||||
}
|
||||
children := make([]*Node, 0, len(node.children))
|
||||
for _, child := range node.children {
|
||||
if child.typ != nodeText || !strings.ContainsAny(child.text, "\r\n") {
|
||||
children = append(children, child)
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case preserveRaw:
|
||||
children = append(children, child)
|
||||
case nextSourceBlock:
|
||||
for _, replacement := range rawTextWithBreakNodes(child.text) {
|
||||
replacement.parent = node
|
||||
children = append(children, replacement)
|
||||
}
|
||||
default:
|
||||
child.text = strings.NewReplacer("\r", "", "\n", "").Replace(child.text)
|
||||
if child.text != "" {
|
||||
children = append(children, child)
|
||||
}
|
||||
}
|
||||
}
|
||||
node.children = children
|
||||
normalizeParsedLineBreaks(node.children, nextSourceBlock, nextStringMutation)
|
||||
}
|
||||
}
|
||||
|
||||
func trimSourceBlockBoundaryNewlines(children []*Node) {
|
||||
for _, child := range children {
|
||||
if child.typ == nodeText {
|
||||
child.text = strings.TrimLeft(child.text, "\r\n")
|
||||
break
|
||||
}
|
||||
if child.typ == nodeElement {
|
||||
break
|
||||
}
|
||||
}
|
||||
for i := len(children) - 1; i >= 0; i-- {
|
||||
child := children[i]
|
||||
if child.typ == nodeText {
|
||||
child.text = strings.TrimRight(child.text, "\r\n")
|
||||
break
|
||||
}
|
||||
if child.typ == nodeElement {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rawTextWithBreakNodes(content string) []*Node {
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
var nodes []*Node
|
||||
start := 0
|
||||
for i := 0; i < len(content); i++ {
|
||||
if content[i] != '\n' && content[i] != '\r' {
|
||||
continue
|
||||
}
|
||||
if i > start {
|
||||
nodes = append(nodes, newText(content[start:i]))
|
||||
}
|
||||
if content[i] == '\r' && i+1 < len(content) && content[i+1] == '\n' {
|
||||
i++
|
||||
}
|
||||
nodes = append(nodes, newElement("br", nil))
|
||||
start = i + 1
|
||||
}
|
||||
if start < len(content) {
|
||||
nodes = append(nodes, newText(content[start:]))
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
type tokenState uint8
|
||||
|
||||
const (
|
||||
tokenOK tokenState = iota
|
||||
tokenInvalid
|
||||
tokenIncomplete
|
||||
tokenComment
|
||||
tokenProcessingInstruction
|
||||
tokenCDATA
|
||||
)
|
||||
|
||||
type xmlToken struct {
|
||||
name string
|
||||
attrs map[string]string
|
||||
text string
|
||||
closing bool
|
||||
selfClosing bool
|
||||
spacingNormalized bool
|
||||
}
|
||||
|
||||
func scanXMLToken(source string, start int) (xmlToken, int, tokenState) {
|
||||
if strings.HasPrefix(source[start:], "<![CDATA[") {
|
||||
const marker = "<![CDATA["
|
||||
contentStart := start + len(marker)
|
||||
if closeAt := strings.Index(source[contentStart:], "]]>"); closeAt >= 0 {
|
||||
contentEnd := contentStart + closeAt
|
||||
return xmlToken{text: source[contentStart:contentEnd]}, contentEnd + len("]]>"), tokenCDATA
|
||||
}
|
||||
return xmlToken{}, len(source), tokenIncomplete
|
||||
}
|
||||
if strings.HasPrefix(source[start:], "<!--") {
|
||||
if closeAt := strings.Index(source[start+4:], "-->"); closeAt >= 0 {
|
||||
if strings.Contains(source[start+4:start+4+closeAt], "--") {
|
||||
return xmlToken{}, start + 1, tokenInvalid
|
||||
}
|
||||
return xmlToken{}, start + 4 + closeAt + 3, tokenComment
|
||||
}
|
||||
return xmlToken{}, len(source), tokenIncomplete
|
||||
}
|
||||
if strings.HasPrefix(source[start:], "<?") {
|
||||
if closeAt := strings.Index(source[start+2:], "?>"); closeAt >= 0 {
|
||||
return xmlToken{}, start + 2 + closeAt + 2, tokenProcessingInstruction
|
||||
}
|
||||
return xmlToken{}, len(source), tokenIncomplete
|
||||
}
|
||||
|
||||
quote := byte(0)
|
||||
end := -1
|
||||
for i := start + 1; i < len(source); i++ {
|
||||
switch source[i] {
|
||||
case '\'', '"':
|
||||
if quote == 0 {
|
||||
quote = source[i]
|
||||
} else if quote == source[i] {
|
||||
quote = 0
|
||||
}
|
||||
case '>':
|
||||
if quote == 0 {
|
||||
end = i + 1
|
||||
i = len(source)
|
||||
}
|
||||
case '<':
|
||||
// A second unquoted '<' cannot belong to the current XML tag.
|
||||
// Stop here so a long sequence of invalid tag starts is scanned
|
||||
// once instead of repeatedly searching to a distant '>'.
|
||||
if quote == 0 {
|
||||
return xmlToken{}, start + 1, tokenInvalid
|
||||
}
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
candidate := strings.TrimSpace(source[start+1:])
|
||||
if candidate == "" || !isTagNameStart(candidate[0]) && candidate[0] != '/' {
|
||||
return xmlToken{}, start + 1, tokenInvalid
|
||||
}
|
||||
return xmlToken{}, len(source), tokenIncomplete
|
||||
}
|
||||
|
||||
body := source[start+1 : end-1]
|
||||
if body == "" {
|
||||
return xmlToken{}, end, tokenInvalid
|
||||
}
|
||||
token := xmlToken{}
|
||||
position := 0
|
||||
for position < len(body) && isXMLSpace(body[position]) {
|
||||
position++
|
||||
}
|
||||
if position > 0 {
|
||||
token.spacingNormalized = true
|
||||
}
|
||||
if position >= len(body) || body[position] == '!' {
|
||||
return xmlToken{}, end, tokenInvalid
|
||||
}
|
||||
if body[position] == '/' {
|
||||
token.closing = true
|
||||
position++
|
||||
spaceStart := position
|
||||
for position < len(body) && isXMLSpace(body[position]) {
|
||||
position++
|
||||
}
|
||||
if position > spaceStart {
|
||||
token.spacingNormalized = true
|
||||
}
|
||||
}
|
||||
if position >= len(body) || !isTagNameStart(body[position]) {
|
||||
return xmlToken{}, end, tokenInvalid
|
||||
}
|
||||
nameStart := position
|
||||
position++
|
||||
for position < len(body) && isTagNamePart(body[position]) {
|
||||
position++
|
||||
}
|
||||
token.name = body[nameStart:position]
|
||||
rawRemainder := body[position:]
|
||||
remainder := strings.TrimRightFunc(rawRemainder, unicode.IsSpace)
|
||||
if token.closing {
|
||||
if strings.TrimSpace(remainder) != "" {
|
||||
return xmlToken{}, end, tokenInvalid
|
||||
}
|
||||
return token, end, tokenOK
|
||||
}
|
||||
if strings.HasSuffix(remainder, "/") {
|
||||
if len(remainder) != len(rawRemainder) {
|
||||
return xmlToken{}, end, tokenInvalid
|
||||
}
|
||||
token.selfClosing = true
|
||||
remainder = strings.TrimRightFunc(strings.TrimSuffix(remainder, "/"), unicode.IsSpace)
|
||||
}
|
||||
trimmedAttrs := strings.TrimLeftFunc(remainder, unicode.IsSpace)
|
||||
if trimmedAttrs != "" && !isAttributeNameStart(trimmedAttrs[0]) {
|
||||
return xmlToken{}, end, tokenInvalid
|
||||
}
|
||||
var ok bool
|
||||
token.attrs, ok = parseStrictAttributes(remainder)
|
||||
if !ok {
|
||||
return xmlToken{}, end, tokenInvalid
|
||||
}
|
||||
return token, end, tokenOK
|
||||
}
|
||||
|
||||
func isXMLSpace(ch byte) bool {
|
||||
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n'
|
||||
}
|
||||
|
||||
func isTagNameStart(ch byte) bool {
|
||||
return ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z'
|
||||
}
|
||||
|
||||
func isTagNamePart(ch byte) bool {
|
||||
return isTagNameStart(ch) || ch >= '0' && ch <= '9' || ch == '_' || ch == '-' || ch == '.' || ch == ':'
|
||||
}
|
||||
|
||||
func isAttributeNameStart(ch byte) bool {
|
||||
return isTagNameStart(ch) || ch == '_' || ch == ':'
|
||||
}
|
||||
|
||||
func parseAttributes(source string) map[string]string {
|
||||
attrs := map[string]string{}
|
||||
for i := 0; i < len(source); {
|
||||
for i < len(source) && unicode.IsSpace(rune(source[i])) {
|
||||
i++
|
||||
}
|
||||
if i >= len(source) {
|
||||
break
|
||||
}
|
||||
start := i
|
||||
for i < len(source) && isAttributeNameByte(source[i]) {
|
||||
i++
|
||||
}
|
||||
if start == i {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
name := source[start:i]
|
||||
for i < len(source) && unicode.IsSpace(rune(source[i])) {
|
||||
i++
|
||||
}
|
||||
value := ""
|
||||
if i < len(source) && source[i] == '=' {
|
||||
i++
|
||||
for i < len(source) && unicode.IsSpace(rune(source[i])) {
|
||||
i++
|
||||
}
|
||||
if i < len(source) && (source[i] == '\'' || source[i] == '"') {
|
||||
quote := source[i]
|
||||
i++
|
||||
start = i
|
||||
for i < len(source) && source[i] != quote {
|
||||
i++
|
||||
}
|
||||
value = source[start:i]
|
||||
if i < len(source) {
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
start = i
|
||||
for i < len(source) && !unicode.IsSpace(rune(source[i])) {
|
||||
i++
|
||||
}
|
||||
value = source[start:i]
|
||||
}
|
||||
}
|
||||
attrs[name] = html.UnescapeString(value)
|
||||
}
|
||||
if len(attrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// parseStrictAttributes implements the quoted attribute grammar accepted by
|
||||
// XML. parseAttributes remains intentionally permissive for the Markdown
|
||||
// container extension, whose input is Markdown rather than an XML document.
|
||||
func parseStrictAttributes(source string) (map[string]string, bool) {
|
||||
attrs := map[string]string{}
|
||||
for i := 0; i < len(source); {
|
||||
spaceStart := i
|
||||
for i < len(source) && isXMLSpace(source[i]) {
|
||||
i++
|
||||
}
|
||||
if i >= len(source) {
|
||||
break
|
||||
}
|
||||
if i == spaceStart || !isAttributeNameStart(source[i]) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
nameStart := i
|
||||
i++
|
||||
for i < len(source) && isTagNamePart(source[i]) {
|
||||
i++
|
||||
}
|
||||
name := source[nameStart:i]
|
||||
if _, exists := attrs[name]; exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for i < len(source) && isXMLSpace(source[i]) {
|
||||
i++
|
||||
}
|
||||
if i >= len(source) || source[i] != '=' {
|
||||
return nil, false
|
||||
}
|
||||
i++
|
||||
for i < len(source) && isXMLSpace(source[i]) {
|
||||
i++
|
||||
}
|
||||
if i >= len(source) || (source[i] != '\'' && source[i] != '"') {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
quote := source[i]
|
||||
i++
|
||||
valueStart := i
|
||||
for i < len(source) && source[i] != quote {
|
||||
if source[i] == '<' {
|
||||
return nil, false
|
||||
}
|
||||
i++
|
||||
}
|
||||
if i >= len(source) {
|
||||
return nil, false
|
||||
}
|
||||
rawValue := normalizeXMLAttributeAmpersands(source[valueStart:i])
|
||||
if invalidXMLEntityAt(rawValue) >= 0 {
|
||||
return nil, false
|
||||
}
|
||||
attrs[name] = html.UnescapeString(rawValue)
|
||||
i++
|
||||
}
|
||||
if len(attrs) == 0 {
|
||||
return nil, true
|
||||
}
|
||||
return attrs, true
|
||||
}
|
||||
|
||||
func isAttributeNameByte(ch byte) bool {
|
||||
return ch > ' ' && ch != '=' && ch != '/' && ch != '>'
|
||||
}
|
||||
|
||||
func appendText(parent *Node, raw string) {
|
||||
if parent == nil || raw == "" {
|
||||
return
|
||||
}
|
||||
appendTextValue(parent, html.UnescapeString(raw))
|
||||
}
|
||||
|
||||
func appendTextValue(parent *Node, text string) {
|
||||
if parent == nil || text == "" {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(text) == "" && !preserveSpaceTags[parent.tag] && parent.tag != "whiteboard" {
|
||||
return
|
||||
}
|
||||
if count := len(parent.children); count > 0 && parent.children[count-1].typ == nodeText {
|
||||
parent.children[count-1].text += text
|
||||
return
|
||||
}
|
||||
parent.addChild(newText(text))
|
||||
}
|
||||
|
||||
func validateXMLText(value string, absoluteOffset int) error {
|
||||
if offset := strings.Index(value, "]]>"); offset >= 0 {
|
||||
return fmt.Errorf("invalid ]]> sequence in XML text at byte %d", absoluteOffset+offset)
|
||||
}
|
||||
if offset := invalidXMLEntityAt(value); offset >= 0 {
|
||||
return fmt.Errorf("invalid XML entity at byte %d", absoluteOffset+offset)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidXMLEntityAt(value string) int {
|
||||
for cursor := 0; cursor < len(value); {
|
||||
relative := strings.IndexByte(value[cursor:], '&')
|
||||
if relative < 0 {
|
||||
return -1
|
||||
}
|
||||
start := cursor + relative
|
||||
endRelative := strings.IndexByte(value[start+1:], ';')
|
||||
if endRelative < 0 {
|
||||
return start
|
||||
}
|
||||
end := start + 1 + endRelative
|
||||
if !isValidXMLEntity(value[start+1 : end]) {
|
||||
return start
|
||||
}
|
||||
cursor = end + 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func isValidXMLEntity(entity string) bool {
|
||||
switch entity {
|
||||
case "amp", "lt", "gt", "quot", "apos":
|
||||
return true
|
||||
}
|
||||
|
||||
base := 10
|
||||
digits := ""
|
||||
switch {
|
||||
case strings.HasPrefix(entity, "#x"):
|
||||
base = 16
|
||||
digits = entity[2:]
|
||||
case strings.HasPrefix(entity, "#"):
|
||||
digits = entity[1:]
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if digits == "" {
|
||||
return false
|
||||
}
|
||||
value, err := strconv.ParseUint(digits, base, 32)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
r := rune(value)
|
||||
return r == '\t' || r == '\n' || r == '\r' ||
|
||||
r >= 0x20 && r <= 0xD7FF ||
|
||||
r >= 0xE000 && r <= 0xFFFD ||
|
||||
r >= 0x10000 && r <= utf8.MaxRune
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeXMLAttributeAmpersands escapes bare ampersands in an XML attribute
|
||||
// value so local parsing matches the server SDK. Complete entity references
|
||||
// remain untouched for the strict parser to validate.
|
||||
func normalizeXMLAttributeAmpersands(value string) string {
|
||||
firstBare := -1
|
||||
for cursor := 0; cursor < len(value); {
|
||||
relative := strings.IndexByte(value[cursor:], '&')
|
||||
if relative < 0 {
|
||||
break
|
||||
}
|
||||
ampersand := cursor + relative
|
||||
if isBareXMLAttributeAmpersand(value, ampersand) {
|
||||
firstBare = ampersand
|
||||
break
|
||||
}
|
||||
cursor = ampersand + 1
|
||||
}
|
||||
if firstBare < 0 {
|
||||
return value
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
out.Grow(len(value))
|
||||
out.WriteString(value[:firstBare])
|
||||
|
||||
for i := firstBare; i < len(value); i++ {
|
||||
if value[i] == '&' && isBareXMLAttributeAmpersand(value, i) {
|
||||
out.WriteString("&")
|
||||
continue
|
||||
}
|
||||
out.WriteByte(value[i])
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func isBareXMLAttributeAmpersand(value string, start int) bool {
|
||||
if start+1 >= len(value) {
|
||||
return true
|
||||
}
|
||||
if value[start+1] == '#' {
|
||||
return false
|
||||
}
|
||||
if !isTagNameStart(value[start+1]) && value[start+1] != '_' {
|
||||
return true
|
||||
}
|
||||
for i := start + 2; i < len(value); i++ {
|
||||
if value[i] == ';' {
|
||||
return false
|
||||
}
|
||||
if !isTagNamePart(value[i]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -33,8 +33,6 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
|
||||
return docsSkillReadCommand + " references/lark-doc-update.md"
|
||||
case "history-list", "history-revert", "history-revert-status":
|
||||
return docsSkillReadCommand + " references/lark-doc-history.md"
|
||||
case "script":
|
||||
return docsSkillReadCommand + " references/lark-doc-script.md"
|
||||
default:
|
||||
return docsSkillReadCommand
|
||||
}
|
||||
@@ -54,8 +52,6 @@ func docsHelpCommandForShortcut(shortcut string) string {
|
||||
return "lark-cli docs +history-revert --help"
|
||||
case "history-revert-status":
|
||||
return "lark-cli docs +history-revert-status --help"
|
||||
case "script":
|
||||
return "lark-cli docs +script --help"
|
||||
default:
|
||||
return "lark-cli docs --help"
|
||||
}
|
||||
@@ -68,7 +64,6 @@ func Shortcuts() []common.Shortcut {
|
||||
DocsCreate,
|
||||
DocsFetch,
|
||||
DocsUpdate,
|
||||
DocsScript,
|
||||
DocsHistoryList,
|
||||
DocsHistoryRevert,
|
||||
DocsHistoryRevertStatus,
|
||||
|
||||
@@ -3,24 +3,11 @@
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var presentationFlagAliases = []string{
|
||||
"presentation-id",
|
||||
"presentation-token",
|
||||
"token",
|
||||
"presentation_id",
|
||||
"xml-presentation-id",
|
||||
"url",
|
||||
}
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all slides shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
all := []common.Shortcut{
|
||||
return []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
@@ -31,39 +18,4 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesHistoryRevert,
|
||||
SlidesHistoryRevertStatus,
|
||||
}
|
||||
for i := range all {
|
||||
if hasPresentationFlag(all[i].Flags) {
|
||||
all[i].PostMount = withPresentationFlagAliases(all[i].PostMount)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func hasPresentationFlag(flags []common.Flag) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == "presentation" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// withPresentationFlagAliases accepts common agent-generated spellings for
|
||||
// --presentation without registering extra flags. The aliases therefore stay
|
||||
// out of help and completion while resolving to the canonical flag at parse
|
||||
// time, matching the zero-round-trip compatibility used by Sheets.
|
||||
func withPresentationFlagAliases(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
for _, alias := range presentationFlagAliases {
|
||||
if name == alias {
|
||||
return pflag.NormalizedName("presentation")
|
||||
}
|
||||
}
|
||||
return pflag.NormalizedName(name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWithPresentationFlagAliases(t *testing.T) {
|
||||
for _, alias := range presentationFlagAliases {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("presentation", "", "presentation reference")
|
||||
withPresentationFlagAliases(nil)(cmd)
|
||||
|
||||
if err := cmd.Flags().Parse([]string{"--" + alias, "presABC"}); err != nil {
|
||||
t.Fatalf("--%s should resolve to --presentation: %v", alias, err)
|
||||
}
|
||||
got, err := cmd.Flags().GetString("presentation")
|
||||
if err != nil {
|
||||
t.Fatalf("read --presentation: %v", err)
|
||||
}
|
||||
if got != "presABC" {
|
||||
t.Fatalf("--%s set --presentation to %q, want presABC", alias, got)
|
||||
}
|
||||
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--"+alias) {
|
||||
t.Fatalf("hidden compatibility alias --%s leaked into help:\n%s", alias, usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutsAttachPresentationFlagAliases(t *testing.T) {
|
||||
count := 0
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if !hasPresentationFlag(shortcut.Flags) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if shortcut.PostMount == nil {
|
||||
t.Errorf("%s has --presentation but no compatibility normalizer", shortcut.Command)
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
cmd.Flags().String("presentation", "", "presentation reference")
|
||||
shortcut.PostMount(cmd)
|
||||
if err := cmd.Flags().Parse([]string{"--token", "presABC"}); err != nil {
|
||||
t.Errorf("%s did not normalize --token: %v", shortcut.Command, err)
|
||||
continue
|
||||
}
|
||||
got, err := cmd.Flags().GetString("presentation")
|
||||
if err != nil {
|
||||
t.Errorf("%s could not read --presentation: %v", shortcut.Command, err)
|
||||
continue
|
||||
}
|
||||
if got != "presABC" {
|
||||
t.Errorf("%s normalized --token to %q, want presABC", shortcut.Command, got)
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatal("expected at least one slides shortcut with --presentation")
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,9 @@ var SlidesScreenshot = common.Shortcut{
|
||||
Command: "+screenshot",
|
||||
Description: "Save up to 10 slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:screenshot"},
|
||||
Scopes: []string{},
|
||||
// The screenshot API is allowlist-gated for only a few apps, so do not
|
||||
// advertise/preflight its scope. Let the API fail and let callers degrade.
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -18,19 +17,23 @@ import (
|
||||
)
|
||||
|
||||
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
|
||||
base := []string{"slides:presentation:screenshot"}
|
||||
if got := SlidesScreenshot.ScopesForIdentity("user"); !reflect.DeepEqual(got, base) {
|
||||
t.Fatalf("user preflight scopes = %#v, want %#v", got, base)
|
||||
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
|
||||
t.Fatalf("user preflight scopes = %#v, want empty", got)
|
||||
}
|
||||
if got := SlidesScreenshot.ScopesForIdentity("bot"); !reflect.DeepEqual(got, base) {
|
||||
t.Fatalf("bot preflight scopes = %#v, want %#v", got, base)
|
||||
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
|
||||
t.Fatalf("bot preflight scopes = %#v, want empty", got)
|
||||
}
|
||||
|
||||
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
want := []string{"wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
for _, scope := range got {
|
||||
if scope == "slides:presentation:screenshot" {
|
||||
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
|
||||
|
||||
@@ -16,9 +16,10 @@ import (
|
||||
)
|
||||
|
||||
// SlidesXMLGet fetches the full XML presentation content. When --output is
|
||||
// provided it writes to a local file; otherwise it returns the XML in the
|
||||
// standard JSON envelope. Use --slide-id or --slide-number to fetch one page,
|
||||
// and use --raw for direct XML stdout.
|
||||
// provided it writes reindented XML to a local file, and --raw prints
|
||||
// reindented XML to stdout; otherwise it returns the server's original
|
||||
// content unmodified in the standard JSON envelope. Use --slide-id or
|
||||
// --slide-number to fetch one page.
|
||||
var SlidesXMLGet = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+xml-get",
|
||||
@@ -30,8 +31,8 @@ var SlidesXMLGet = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "output", Desc: "local XML output path; must be a relative path within the current directory; existing file is overwritten; omit to return XML in the JSON envelope"},
|
||||
{Name: "raw", Type: "bool", Desc: "print raw XML to stdout instead of the JSON envelope; incompatible with --output and --jq"},
|
||||
{Name: "output", Desc: "local XML output path; the saved file is formatted for readability; must be a relative path within the current directory; existing file is overwritten; omit to return the server's original XML in the JSON envelope"},
|
||||
{Name: "raw", Type: "bool", Desc: "print formatted XML to stdout without the JSON envelope; incompatible with --output and --jq"},
|
||||
{Name: "slide-id", Desc: "slide page identifier; omit both slide selectors to fetch full presentation XML"},
|
||||
{Name: "slide-number", Type: "int", Desc: "1-based slide page number; omit both slide selectors to fetch full presentation XML"},
|
||||
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
|
||||
@@ -108,10 +109,10 @@ var SlidesXMLGet = common.Shortcut{
|
||||
}
|
||||
dry.GET(path).Params(params)
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
return dry.Set("output", outputPath).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
|
||||
return dry.Set("output", outputPath).Set("stdout_content", "suppressed; formatted XML content is saved to --output during execution")
|
||||
}
|
||||
if runtime.Bool("raw") {
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "raw XML content is printed to stdout during execution")
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "formatted XML content is printed to stdout during execution")
|
||||
}
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "JSON envelope with XML content is printed to stdout during execution")
|
||||
},
|
||||
@@ -250,22 +251,31 @@ func fetchSlidesXMLGetContent(runtime *common.RuntimeContext, presentationID str
|
||||
return content, out, nil
|
||||
}
|
||||
|
||||
// outputSlidesXMLGetContent routes the fetched XML to its output surface.
|
||||
// Only the text surfaces are reindented: --raw stdout and --output files are
|
||||
// read directly by humans and line tools. The JSON envelope carries the
|
||||
// server content verbatim instead -- inside a JSON string every newline is
|
||||
// escaped to \n, so formatting there buys no readability and only inflates
|
||||
// the payload, while passthrough keeps that read path byte-exact without
|
||||
// even parsing the content.
|
||||
func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, outputPath string, out map[string]interface{}) error {
|
||||
if outputPath == "" {
|
||||
if !runtime.Bool("raw") {
|
||||
runtime.OutFormatRaw(out, nil, nil)
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprint(runtime.IO().Out, content); err != nil {
|
||||
formatted, _ := prettyPrintXMLOrOriginal(runtime, content)
|
||||
if _, err := fmt.Fprint(runtime.IO().Out, formatted); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "write XML content to stdout: %v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
formatted, prettyPrinted := prettyPrintXMLOrOriginal(runtime, content)
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(content)),
|
||||
}, bytes.NewReader([]byte(content)))
|
||||
ContentLength: int64(len(formatted)),
|
||||
}, bytes.NewReader([]byte(formatted)))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
@@ -280,6 +290,7 @@ func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, o
|
||||
"path": resolvedPath,
|
||||
"size": result.Size(),
|
||||
"content_saved": true,
|
||||
"pretty_printed": prettyPrinted,
|
||||
}
|
||||
for _, key := range []string{"revision_id", "remove_attr_id", "slide_id", "slide_number"} {
|
||||
if value, ok := out[key]; ok {
|
||||
@@ -289,3 +300,17 @@ func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, o
|
||||
runtime.Out(fileOut, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// prettyPrintXMLOrOriginal keeps xml-get best-effort: if the server returns
|
||||
// content that is not strictly valid XML, callers still receive the original
|
||||
// content and a warning on stderr instead of losing the read path. The bool
|
||||
// reports whether pretty-printing succeeded, surfaced as pretty_printed in
|
||||
// --output file metadata.
|
||||
func prettyPrintXMLOrOriginal(runtime *common.RuntimeContext, xmlContent string) (string, bool) {
|
||||
out, err := prettyPrintXML(xmlContent)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: XML pretty-print skipped; returning original server content: %v\n", err)
|
||||
return xmlContent, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
// Golden value computed independently of prettyPrintXML (not derived by
|
||||
// calling it): a bug in prettyPrintXML itself must not be able to make
|
||||
// this assertion pass by construction.
|
||||
wantXML := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -60,10 +64,10 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read saved XML: %v", err)
|
||||
}
|
||||
if string(got) != xml {
|
||||
t.Fatalf("saved XML = %q, want %q", got, xml)
|
||||
if string(got) != wantXML {
|
||||
t.Fatalf("saved XML = %q, want %q", got, wantXML)
|
||||
}
|
||||
if strings.Contains(stdout.String(), xml) {
|
||||
if strings.Contains(stdout.String(), wantXML) {
|
||||
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
|
||||
}
|
||||
if got := capturedQuery.Get("revision_id"); got != "7" {
|
||||
@@ -80,8 +84,11 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
if data["revision_id"] != float64(7) {
|
||||
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
|
||||
}
|
||||
if data["size"] != float64(len(xml)) {
|
||||
t.Fatalf("size = %v, want %d", data["size"], len(xml))
|
||||
if data["pretty_printed"] != true {
|
||||
t.Fatalf("pretty_printed = %v, want true", data["pretty_printed"])
|
||||
}
|
||||
if data["size"] != float64(len(wantXML)) {
|
||||
t.Fatalf("size = %v, want %d", data["size"], len(wantXML))
|
||||
}
|
||||
gotPath, _ := data["path"].(string)
|
||||
if !filepath.IsAbs(gotPath) {
|
||||
@@ -96,7 +103,12 @@ func TestSlidesXMLGetReturnsContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
// The JSON envelope carries the server content verbatim: no reindentation
|
||||
// and no parse/reserialize cycle. Reintroducing the in-repo formatter
|
||||
// would fail this by inserting indentation; the   reference
|
||||
// additionally guards against a naive parse-and-reserialize round trip,
|
||||
// which would decode it to a literal space.
|
||||
xml := `<presentation><slide id="s1"><shape id="a"><content><p><span>Hello</span> <strong>World</strong></p></content></shape></slide></presentation>`
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
@@ -122,11 +134,14 @@ func TestSlidesXMLGetReturnsContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
data := decodeShortcutData(t, stdout)
|
||||
presentation := data["xml_presentation"].(map[string]interface{})
|
||||
if got := presentation["content"]; got != xml {
|
||||
t.Fatalf("content = %q, want %q", got, xml)
|
||||
t.Fatalf("content = %q, want the server content verbatim %q", got, xml)
|
||||
}
|
||||
if got := data["xml_presentation_id"]; got != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_abc", got)
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "content_saved") {
|
||||
t.Fatalf("stdout should not contain file metadata: %s", stdout.String())
|
||||
}
|
||||
@@ -136,6 +151,8 @@ func TestSlidesXMLGetJqFiltersContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
// --jq extracts fields from the envelope, and the envelope carries the
|
||||
// server content verbatim, so the filter yields the single-line original.
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -161,15 +178,18 @@ func TestSlidesXMLGetJqFiltersContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != xml {
|
||||
t.Fatalf("stdout = %q, want XML content %q", got, xml)
|
||||
t.Fatalf("stdout = %q, want the server content verbatim %q", got, xml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetPrintsRawContentWhenRaw(t *testing.T) {
|
||||
func TestSlidesXMLGetPrintsFormattedContentWithoutEnvelopeWhenRaw(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
// Golden value computed independently of prettyPrintXML; see the comment
|
||||
// in TestSlidesXMLGetWritesContentToFileAndSuppressesXML.
|
||||
wantXML := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
@@ -193,16 +213,32 @@ func TestSlidesXMLGetPrintsRawContentWhenRaw(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != xml {
|
||||
t.Fatalf("stdout = %q, want raw XML %q", got, xml)
|
||||
if got := stdout.String(); got != wantXML {
|
||||
t.Fatalf("stdout = %q, want formatted XML %q", got, wantXML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetRawFlagDocumentsFormattedOutput(t *testing.T) {
|
||||
for _, flag := range SlidesXMLGet.Flags {
|
||||
if flag.Name != "raw" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(flag.Desc, "formatted XML") || strings.Contains(flag.Desc, "raw XML") {
|
||||
t.Fatalf("--raw description = %q, want formatted XML without a raw-payload claim", flag.Desc)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("--raw flag not found")
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetFetchesSingleSlideByIDToFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<slide id="slide_1"><data><shape id="a"/></data></slide>`
|
||||
// Golden value computed independently of prettyPrintXML; see the comment
|
||||
// in TestSlidesXMLGetWritesContentToFileAndSuppressesXML.
|
||||
wantXML := "<slide id=\"slide_1\">\n <data>\n <shape id=\"a\"/>\n </data>\n</slide>\n"
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -244,8 +280,8 @@ func TestSlidesXMLGetFetchesSingleSlideByIDToFile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read saved slide XML: %v", err)
|
||||
}
|
||||
if string(got) != xml {
|
||||
t.Fatalf("saved XML = %q, want %q", got, xml)
|
||||
if string(got) != wantXML {
|
||||
t.Fatalf("saved XML = %q, want %q", got, wantXML)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["scope"] != "slide" {
|
||||
@@ -263,6 +299,8 @@ func TestSlidesXMLGetFetchesSingleSlideByNumberEnvelope(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
// The slide envelope carries the server content verbatim, like the
|
||||
// presentation envelope.
|
||||
xml := `<slide id="slide_2"><data><shape id="b"/></data></slide>`
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
@@ -305,11 +343,14 @@ func TestSlidesXMLGetFetchesSingleSlideByNumberEnvelope(t *testing.T) {
|
||||
}
|
||||
slide := data["slide"].(map[string]interface{})
|
||||
if slide["content"] != xml {
|
||||
t.Fatalf("content = %q, want %q", slide["content"], xml)
|
||||
t.Fatalf("content = %q, want the server content verbatim %q", slide["content"], xml)
|
||||
}
|
||||
if slide["slide_id"] != "slide_2" {
|
||||
t.Fatalf("slide.slide_id = %v, want slide_2", slide["slide_id"])
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
|
||||
@@ -515,3 +556,341 @@ func TestSlidesXMLGetRejectsRemoveAttrIDForSingleSlide(t *testing.T) {
|
||||
t.Fatalf("param = %q, want --remove-attr-id", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXML(t *testing.T) {
|
||||
input := `<presentation id="p1" xmlns="http://www.larkoffice.com/sml/2.0" width="960"><slide id="s1"><style><fill id="f1"><fillColor color="rgba(0,0,0,1)"/></fill></style><data/></slide></presentation>`
|
||||
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "\n") {
|
||||
t.Fatalf("expected reindented output with newlines, got %q", got)
|
||||
}
|
||||
if n := strings.Count(got, `xmlns="http://www.larkoffice.com/sml/2.0"`); n != 1 {
|
||||
t.Fatalf("expected the xmlns declaration to appear exactly once, got %d occurrences in %q", n, got)
|
||||
}
|
||||
if !strings.Contains(got, "<data/>") {
|
||||
t.Fatalf("expected empty <data/> to stay self-closing, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `<fillColor color="rgba(0,0,0,1)"/>`) {
|
||||
t.Fatalf("expected attributes to be preserved on their element, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLRejectsMalformedInput(t *testing.T) {
|
||||
if _, err := prettyPrintXML(`<presentation><slide></presentation>`); err == nil {
|
||||
t.Fatal("expected an error for malformed XML, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLPreservesEscapedWhitespaceReferences covers the schema's
|
||||
// documented space/tab escape idiom (slides_xml_schema_definition.xml, <p>
|
||||
// element docs) and CR/LF references whose lexical form is needed to avoid
|
||||
// XML line-ending normalization on a later parse. An XML parser decodes the
|
||||
// references into literal whitespace. The formatter must preserve their
|
||||
// lexical representation for safe read-modify-write workflows.
|
||||
func TestPrettyPrintXMLPreservesEscapedWhitespaceReferences(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"space in p", `<content><p> </p></content>`, "<content>\n <p> </p>\n</content>\n"},
|
||||
{"tab in p", `<content><p>	</p></content>`, "<content>\n <p>	</p>\n</content>\n"},
|
||||
{"space in nested span", `<content><p><span> </span></p></content>`, "<content>\n <p><span> </span></p>\n</content>\n"},
|
||||
{"hex space", `<content><p> </p></content>`, "<content>\n <p> </p>\n</content>\n"},
|
||||
{"zero-padded tab", `<content><p>	</p></content>`, "<content>\n <p>	</p>\n</content>\n"},
|
||||
{"carriage return", `<content><p>A B</p></content>`, "<content>\n <p>A B</p>\n</content>\n"},
|
||||
{"line feed", `<content><p>A B</p></content>`, "<content>\n <p>A B</p>\n</content>\n"},
|
||||
{"hex carriage return", `<content><p>A
B</p></content>`, "<content>\n <p>A
B</p>\n</content>\n"},
|
||||
{"hex line feed", `<content><p>A
B</p></content>`, "<content>\n <p>A
B</p>\n</content>\n"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLPreservesTextOnlyLeafWhitespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "title literal space",
|
||||
input: `<presentation><title> </title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title> </title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "title escaped space",
|
||||
input: `<presentation><title> </title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title> </title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "title whitespace CDATA",
|
||||
input: `<presentation><title><![CDATA[ ]]></title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title><![CDATA[ ]]></title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "chart field literal space",
|
||||
input: `<chartData><chartField name="x"> </chartField></chartData>`,
|
||||
want: "<chartData>\n <chartField name=\"x\"> </chartField>\n</chartData>\n",
|
||||
},
|
||||
{
|
||||
name: "title adjacent text and CDATA",
|
||||
input: `<presentation><title> <![CDATA[ ]]></title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title> <![CDATA[ ]]></title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLPreservesEscapedSpaceBetweenInlineSiblings is the
|
||||
// critical case:   sitting as a bare sibling text node directly between
|
||||
// two inline elements, not wrapped in its own tag -- the literal reading of
|
||||
// the schema's "标签之间...请使用 " guidance, e.g. a plain-styled space
|
||||
// between two differently formatted words at a pptx run boundary. A fix
|
||||
// that only special-cases "element whose sole content is whitespace" does
|
||||
// not cover this: the whitespace here is one of several children of <p>,
|
||||
// not the sole child of <span>.
|
||||
func TestPrettyPrintXMLPreservesEscapedSpaceBetweenInlineSiblings(t *testing.T) {
|
||||
input := `<content><p><span>Hello</span> <strong>World</strong></p></content>`
|
||||
want := "<content>\n <p><span>Hello</span> <strong>World</strong></p>\n</content>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLPreservesCDATA(t *testing.T) {
|
||||
input := `<content><p><![CDATA[a-->b & <c>]]></p></content>`
|
||||
want := "<content>\n <p><![CDATA[a-->b & <c>]]></p>\n</content>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLSeparatesParagraphsWithoutTouchingTheirText is the
|
||||
// feature's actual point: a shape with many paragraphs becomes navigable
|
||||
// (each <p> on its own indented line), while every paragraph's own rich
|
||||
// text -- including an inline formatting boundary -- stays byte-for-byte
|
||||
// unchanged.
|
||||
func TestPrettyPrintXMLSeparatesParagraphsWithoutTouchingTheirText(t *testing.T) {
|
||||
input := `<content><p>First paragraph.</p><p>Second <strong>paragraph</strong>.</p></content>`
|
||||
want := "<content>\n <p>First paragraph.</p>\n <p>Second <strong>paragraph</strong>.</p>\n</content>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLIdempotent(t *testing.T) {
|
||||
input := `<presentation><slide id="s1"><shape id="a"><content><p>A  B	C D E</p></content><style/></shape></slide></presentation>`
|
||||
once, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML (first pass): %v", err)
|
||||
}
|
||||
twice, err := prettyPrintXML(once)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML (second pass): %v", err)
|
||||
}
|
||||
if once != twice {
|
||||
t.Fatalf("not idempotent:\nonce: %q\ntwice: %q", once, twice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetFallsBackToOriginalPresentationWhenReformatFails(t *testing.T) {
|
||||
content := "<presentation><title>\x0b</title><slide/></presentation>"
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--raw",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != content {
|
||||
t.Fatalf("stdout = %q, want original content %q", got, content)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: XML pretty-print skipped; returning original server content:") {
|
||||
t.Fatalf("stderr = %q, want explicit pretty-print fallback warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesXMLGetEnvelopePassesThroughMalformedSlideContent pins the
|
||||
// envelope contract: the content is never parsed, so even malformed XML
|
||||
// flows through byte for byte with no fallback warning and no
|
||||
// pretty_printed field.
|
||||
func TestSlidesXMLGetEnvelopePassesThroughMalformedSlideContent(t *testing.T) {
|
||||
content := `<slide><data></slide>`
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide": map[string]interface{}{
|
||||
"slide_id": "slide_1",
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
slide, _ := data["slide"].(map[string]interface{})
|
||||
if slide == nil {
|
||||
t.Fatalf("missing slide: %#v", data)
|
||||
}
|
||||
if got, _ := slide["content"].(string); got != content {
|
||||
t.Fatalf("slide.content = %q, want the server content verbatim %q", got, content)
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
if got := stderr.String(); got != "" {
|
||||
t.Fatalf("stderr = %q, want empty: the envelope path must not parse the content", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesXMLGetEnvelopePassesThroughMalformedPresentationContent mirrors
|
||||
// the slide-scope passthrough test for the presentation-scope fetch branch,
|
||||
// which is a separate code path.
|
||||
func TestSlidesXMLGetEnvelopePassesThroughMalformedPresentationContent(t *testing.T) {
|
||||
content := `<presentation><slide></presentation>`
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
presentation, _ := data["xml_presentation"].(map[string]interface{})
|
||||
if presentation == nil {
|
||||
t.Fatalf("missing xml_presentation: %#v", data)
|
||||
}
|
||||
if got, _ := presentation["content"].(string); got != content {
|
||||
t.Fatalf("content = %q, want the server content verbatim %q", got, content)
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
if got := stderr.String(); got != "" {
|
||||
t.Fatalf("stderr = %q, want empty: the envelope path must not parse the content", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetFileMetadataReportsPrettyPrintFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
content := `<presentation><slide></presentation>`
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "fallback.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, "fallback.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read fallback XML: %v", err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Fatalf("saved XML = %q, want original content %q", got, content)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["pretty_printed"] != false {
|
||||
t.Fatalf("pretty_printed = %v, want false", data["pretty_printed"])
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: XML pretty-print skipped; returning original server content:") {
|
||||
t.Fatalf("stderr = %q, want explicit pretty-print fallback warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
260
shortcuts/slides/slides_xml_prettyprint.go
Normal file
260
shortcuts/slides/slides_xml_prettyprint.go
Normal file
@@ -0,0 +1,260 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// textBearingTags are the SML elements whose schema content model is
|
||||
// mixed (arbitrary text interleaved with inline markup): the <p> paragraph
|
||||
// container and its inline formatting children, plus chart title/subtitle.
|
||||
// See slides_xml_schema_definition.xml, <p> element docs: a deliberate space
|
||||
// or tab is represented via  /	 character references. Reindentation
|
||||
// never descends into these elements; their entire subtree is copied
|
||||
// verbatim from the input, so those references keep their exact spelling.
|
||||
var textBearingTags = map[string]bool{
|
||||
"p": true,
|
||||
"strong": true,
|
||||
"em": true,
|
||||
"u": true,
|
||||
"span": true,
|
||||
"del": true,
|
||||
"a": true,
|
||||
"shadow": true,
|
||||
"outline": true,
|
||||
"chartTitle": true,
|
||||
"chartSubTitle": true,
|
||||
}
|
||||
|
||||
// tokenKind classifies a raw XML token for reindentation purposes.
|
||||
type tokenKind uint8
|
||||
|
||||
const (
|
||||
tokenStartElement tokenKind = iota // <name ...> or <name .../>
|
||||
tokenEndElement // </name>, or zero-width after <name .../>
|
||||
tokenCharData // text, character/entity references, or one CDATA section
|
||||
tokenOther // comment, processing instruction, or directive
|
||||
)
|
||||
|
||||
// rawToken records where one XML token lives inside the original input:
|
||||
// input[start:end] is the token's exact source bytes. The decoded token
|
||||
// value is deliberately discarded (only the element's local name is kept),
|
||||
// which is the core invariant of this formatter: output can only ever be
|
||||
// assembled from verbatim slices of the input, never from re-encoded data.
|
||||
type rawToken struct {
|
||||
kind tokenKind
|
||||
start int // byte offset of the token's first source byte
|
||||
end int // byte offset one past the token's last source byte
|
||||
local string // local element name (namespace prefix stripped); start elements only
|
||||
match int // start element: index of its matching end token; -1 otherwise
|
||||
}
|
||||
|
||||
// tokenize runs encoding/xml over the whole input purely as a tokenizer and
|
||||
// returns every token annotated with its raw byte range. Ranges come from
|
||||
// Decoder.InputOffset, which counts bytes (multi-byte UTF-8 content cannot
|
||||
// skew them), and consecutive tokens tile the input exactly, so slicing
|
||||
// between them loses nothing.
|
||||
//
|
||||
// The full document is decoded before anything is emitted: any syntax error
|
||||
// (mismatched or unclosed tags, invalid characters such as \x0b, undefined
|
||||
// entities, bare ]]> in text, ...) fails the whole pretty-print, keeping the
|
||||
// strict-parse behavior the fallback path in prettyPrintXMLOrOriginal
|
||||
// depends on.
|
||||
func tokenize(input string) ([]rawToken, error) {
|
||||
decoder := xml.NewDecoder(strings.NewReader(input))
|
||||
var tokens []rawToken
|
||||
var openElements []int // indices into tokens of currently open start elements
|
||||
pos := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end := int(decoder.InputOffset())
|
||||
raw := rawToken{start: pos, end: end, match: -1}
|
||||
switch t := token.(type) {
|
||||
case xml.StartElement:
|
||||
raw.kind = tokenStartElement
|
||||
raw.local = t.Name.Local
|
||||
openElements = append(openElements, len(tokens))
|
||||
case xml.EndElement:
|
||||
// A strict decoder never emits an end element without its start
|
||||
// element; guard anyway so a decoder change cannot panic here.
|
||||
if len(openElements) == 0 {
|
||||
return nil, errors.New("xml: unexpected end element")
|
||||
}
|
||||
raw.kind = tokenEndElement
|
||||
startIndex := openElements[len(openElements)-1]
|
||||
openElements = openElements[:len(openElements)-1]
|
||||
tokens[startIndex].match = len(tokens)
|
||||
case xml.CharData:
|
||||
raw.kind = tokenCharData
|
||||
default: // xml.Comment, xml.ProcInst, xml.Directive
|
||||
raw.kind = tokenOther
|
||||
}
|
||||
tokens = append(tokens, raw)
|
||||
pos = end
|
||||
}
|
||||
// A strict decoder reports unclosed elements as a syntax error before
|
||||
// returning io.EOF; guard anyway so truncated output is impossible.
|
||||
if len(openElements) != 0 {
|
||||
return nil, errors.New("xml: unexpected EOF: unclosed element")
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// prettyPrintXML reindents xmlContent so structural elements (presentation,
|
||||
// slide, shape, style, ...) each sit on their own line. The server returns
|
||||
// XML as a single unbroken line, and this is what makes the --raw and
|
||||
// --output text surfaces readable; the JSON envelope path never calls it
|
||||
// (see outputSlidesXMLGetContent).
|
||||
//
|
||||
// Offset-slicing invariant: encoding/xml serves purely as a tokenizer, and
|
||||
// every byte of the output is either a verbatim slice of the input or an
|
||||
// inserted "\n"+indent run between the children of a structural element.
|
||||
// Nothing is parsed-and-reserialized, so CDATA sections, whitespace
|
||||
// character references in any spelling ( ,  , 	, ,
|
||||
// , ...), entity lexical forms, attribute quoting, and in-tag
|
||||
// whitespace all survive byte-for-byte.
|
||||
//
|
||||
// Reindentation never enters a textBearingTags element and never touches a
|
||||
// leaf element (one with no element children), so document text — including
|
||||
// whitespace-only leaves such as <title> </title> — is never altered.
|
||||
func prettyPrintXML(xmlContent string) (string, error) {
|
||||
tokens, err := tokenize(xmlContent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The decoder tolerates element-free input (plain text, a lone comment,
|
||||
// nothing at all). A document without a root element is not XML the
|
||||
// formatter should claim success on; erroring routes it to the
|
||||
// original-content fallback instead of reporting pretty_printed: true.
|
||||
if !slices.ContainsFunc(tokens, func(t rawToken) bool { return t.kind == tokenStartElement }) {
|
||||
return "", errors.New("xml: no root element")
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(xmlContent) + len(xmlContent)/8)
|
||||
reindented := false
|
||||
for i := 0; i < len(tokens); {
|
||||
token := tokens[i]
|
||||
if token.kind == tokenStartElement {
|
||||
if reindented {
|
||||
// Any top-level element after the first is copied verbatim;
|
||||
// well-formed XML has a single root, so this arm only runs
|
||||
// on technically invalid multi-root input the decoder
|
||||
// happens to tolerate.
|
||||
out.WriteString(xmlContent[token.start:tokens[token.match].end])
|
||||
} else {
|
||||
writeElement(&out, xmlContent, tokens, i, 0)
|
||||
reindented = true
|
||||
}
|
||||
i = token.match + 1
|
||||
continue
|
||||
}
|
||||
// Document-level prolog and epilog (XML declaration, DOCTYPE,
|
||||
// comments, whitespace) pass through verbatim.
|
||||
out.WriteString(xmlContent[token.start:token.end])
|
||||
i++
|
||||
}
|
||||
formatted := out.String()
|
||||
if !strings.HasSuffix(formatted, "\n") {
|
||||
formatted += "\n"
|
||||
}
|
||||
return formatted, nil
|
||||
}
|
||||
|
||||
// writeElement emits the element whose start token is tokens[startIndex],
|
||||
// indented as if at the given depth (two spaces per level).
|
||||
//
|
||||
// Text-bearing elements and leaf elements (no element children) are emitted
|
||||
// as a single verbatim input slice from open tag through close tag; for a
|
||||
// self-closing tag the synthesized end token is zero-width and the slice is
|
||||
// exactly the open tag. Structural elements (at least one element child,
|
||||
// not text-bearing) are reindented: text children that are pure literal
|
||||
// whitespace are dropped as pre-existing formatting, "\n"+indent is
|
||||
// inserted before every element, comment, and processing-instruction child,
|
||||
// kept text children stay glued in place with no indentation around them,
|
||||
// and the close tag moves to its own line unless the last kept child is
|
||||
// text.
|
||||
//
|
||||
// The whitespace-only test runs on the child's RAW source bytes: a
|
||||
// character reference ( ) or a CDATA section is not literal whitespace
|
||||
// there, so it is kept and its lexical form survives.
|
||||
func writeElement(out *strings.Builder, input string, tokens []rawToken, startIndex, depth int) {
|
||||
start := tokens[startIndex]
|
||||
end := tokens[start.match]
|
||||
if textBearingTags[start.local] || !hasElementChild(tokens, startIndex) {
|
||||
out.WriteString(input[start.start:end.end])
|
||||
return
|
||||
}
|
||||
|
||||
out.WriteString(input[start.start:start.end])
|
||||
childIndent := "\n" + strings.Repeat(" ", depth+1)
|
||||
lastKeptIsText := false
|
||||
for i := startIndex + 1; i < start.match; {
|
||||
child := tokens[i]
|
||||
switch child.kind {
|
||||
case tokenCharData:
|
||||
if !isAllWhitespace(input[child.start:child.end]) {
|
||||
out.WriteString(input[child.start:child.end])
|
||||
lastKeptIsText = true
|
||||
}
|
||||
i++
|
||||
case tokenStartElement:
|
||||
out.WriteString(childIndent)
|
||||
writeElement(out, input, tokens, i, depth+1)
|
||||
lastKeptIsText = false
|
||||
i = child.match + 1
|
||||
default: // comment, processing instruction, directive
|
||||
out.WriteString(childIndent)
|
||||
out.WriteString(input[child.start:child.end])
|
||||
lastKeptIsText = false
|
||||
i++
|
||||
}
|
||||
}
|
||||
if !lastKeptIsText {
|
||||
out.WriteString("\n")
|
||||
out.WriteString(strings.Repeat(" ", depth))
|
||||
}
|
||||
out.WriteString(input[end.start:end.end])
|
||||
}
|
||||
|
||||
// hasElementChild reports whether the element starting at tokens[startIndex]
|
||||
// has at least one direct element child. The first start-element token that
|
||||
// appears before the matching end token is necessarily a direct child, so a
|
||||
// linear scan without depth tracking suffices.
|
||||
func hasElementChild(tokens []rawToken, startIndex int) bool {
|
||||
for i := startIndex + 1; i < tokens[startIndex].match; i++ {
|
||||
if tokens[i].kind == tokenStartElement {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isAllWhitespace reports whether s is non-empty and consists only of
|
||||
// literal XML whitespace bytes (space, tab, CR, LF). It is applied to raw
|
||||
// source bytes, where character references and CDATA markers count as
|
||||
// non-whitespace by construction.
|
||||
func isAllWhitespace(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case ' ', '\t', '\n', '\r':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
416
shortcuts/slides/slides_xml_prettyprint_test.go
Normal file
416
shortcuts/slides/slides_xml_prettyprint_test.go
Normal file
@@ -0,0 +1,416 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The pure-function contract tests for prettyPrintXML (golden strings,
|
||||
// whitespace character references, leaf whitespace, CDATA, idempotency,
|
||||
// malformed rejection) live in slides_xml_get_test.go, unchanged from the
|
||||
// original etree-based implementation. This file adds engine-level cases
|
||||
// specific to the offset-slicing implementation.
|
||||
|
||||
func TestPrettyPrintXMLGoldenPresentation(t *testing.T) {
|
||||
input := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
want := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLGoldenSlide(t *testing.T) {
|
||||
input := `<slide id="slide_1"><data><shape id="a"/></data></slide>`
|
||||
want := "<slide id=\"slide_1\">\n <data>\n <shape id=\"a\"/>\n </data>\n</slide>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLRejectsMalformedInputTable pins that the whole document
|
||||
// is decoded before anything is emitted: even a late syntax error yields no
|
||||
// partial output, only the error the fallback path reports.
|
||||
func TestPrettyPrintXMLRejectsMalformedInputTable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"mismatched close tag", `<presentation><slide></presentation>`},
|
||||
{"unclosed slide from fallback test", `<slide><data></slide>`},
|
||||
{"invalid control character", "<presentation><title>\x0b</title><slide/></presentation>"},
|
||||
{"unclosed root", `<presentation><slide/>`},
|
||||
{"undefined entity", `<presentation><title> </title></presentation>`},
|
||||
{"bare close tag", `</presentation>`},
|
||||
{"unescaped cdata terminator in text", `<presentation><title>a]]>b</title></presentation>`},
|
||||
{"late error after valid prefix", `<presentation><slide/><slide/><slide id=></presentation>`},
|
||||
{"empty input", ``},
|
||||
{"whitespace-only input", ` `},
|
||||
{"plain text without markup", `hello`},
|
||||
{"comment-only document", `<!-- only a comment -->`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err == nil {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want error", tt.input, got)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("prettyPrintXML(%q) returned partial output %q alongside error %v", tt.input, got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLIgnoresMaskingEraPlaceholderText pins that user content
|
||||
// resembling the previous implementation's masking placeholders
|
||||
// (LARKCLI_XML_WHITESPACE_REFERENCE_<n>_) flows through untouched now that
|
||||
// no masking exists at all.
|
||||
func TestPrettyPrintXMLIgnoresMaskingEraPlaceholderText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "placeholder-shaped text in p",
|
||||
input: `<content><p>LARKCLI_XML_WHITESPACE_REFERENCE_0_ end</p></content>`,
|
||||
want: "<content>\n <p>LARKCLI_XML_WHITESPACE_REFERENCE_0_ end</p>\n</content>\n",
|
||||
},
|
||||
{
|
||||
name: "placeholder-shaped text in leaf",
|
||||
input: `<presentation><title>LARKCLI_XML_WHITESPACE_REFERENCE_1_</title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title>LARKCLI_XML_WHITESPACE_REFERENCE_1_</title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "placeholder-shaped attribute value",
|
||||
input: `<presentation><slide note="LARKCLI_XML_WHITESPACE_REFERENCE_0_"><shape/></slide></presentation>`,
|
||||
want: "<presentation>\n <slide note=\"LARKCLI_XML_WHITESPACE_REFERENCE_0_\">\n <shape/>\n </slide>\n</presentation>\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLStructuralTable covers comments, processing
|
||||
// instructions, prolog/DOCTYPE, mixed text between structural children,
|
||||
// CRLF pre-formatting, and multi-byte UTF-8 around offset boundaries.
|
||||
// Expected outputs were verified byte-identical against the previous
|
||||
// etree-based implementation via a differential probe.
|
||||
func TestPrettyPrintXMLStructuralTable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
// wantSecond is the expected output of formatting the output again.
|
||||
// Usually equal to want (idempotent); the mixed-content rows pin the
|
||||
// one known non-idempotent shape, where kept text merges with the
|
||||
// inserted indent on reparse — byte-identical to the previous
|
||||
// implementation's behavior on the same inputs. Real SML structural
|
||||
// elements carry no mixed text, so the contract's idempotency
|
||||
// guarantee is unaffected.
|
||||
wantSecond string
|
||||
}{
|
||||
{
|
||||
name: "comment child is indented like an element",
|
||||
input: `<presentation><!-- deck notes --><slide/></presentation>`,
|
||||
want: "<presentation>\n <!-- deck notes -->\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "processing instruction child is indented like an element",
|
||||
input: `<presentation><?pi data?><slide/></presentation>`,
|
||||
want: "<presentation>\n <?pi data?>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "xml declaration prolog stays glued to the root",
|
||||
input: `<?xml version="1.0" encoding="UTF-8"?><presentation><slide/></presentation>`,
|
||||
want: "<?xml version=\"1.0\" encoding=\"UTF-8\"?><presentation>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "prolog with doctype and trailing newline preserved verbatim",
|
||||
input: "<?xml version=\"1.0\"?>\n<!DOCTYPE presentation>\n<presentation><slide/></presentation>\n",
|
||||
want: "<?xml version=\"1.0\"?>\n<!DOCTYPE presentation>\n<presentation>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "document-level trailing comment preserved verbatim",
|
||||
input: "<presentation><slide/></presentation><!-- tail -->",
|
||||
want: "<presentation>\n <slide/>\n</presentation><!-- tail -->\n",
|
||||
},
|
||||
{
|
||||
name: "kept mixed text glues to previous sibling and close tag",
|
||||
input: `<data>x<child/>y</data>`,
|
||||
want: "<data>x\n <child/>y</data>\n",
|
||||
wantSecond: "<data>x\n \n <child/>y</data>\n",
|
||||
},
|
||||
{
|
||||
name: "kept mixed text does not suppress indent of next element",
|
||||
input: `<data>x<child/>y<child/></data>`,
|
||||
want: "<data>x\n <child/>y\n <child/>\n</data>\n",
|
||||
wantSecond: "<data>x\n \n <child/>y\n \n <child/>\n</data>\n",
|
||||
},
|
||||
{
|
||||
name: "pre-existing CRLF formatting is dropped and rebuilt",
|
||||
input: "<presentation>\r\n\t<slide/>\r\n</presentation>",
|
||||
want: "<presentation>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "multi-byte UTF-8 text and attributes keep exact bytes",
|
||||
input: `<presentation><title>原生图表 📊 Chart</title><slide 备注="中文värde"><shape/></slide></presentation>`,
|
||||
want: "<presentation>\n <title>原生图表 📊 Chart</title>\n <slide 备注=\"中文värde\">\n <shape/>\n </slide>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "namespace-prefixed p is still text-bearing",
|
||||
input: `<content xmlns:sml="urn:x"><sml:p><span>a</span> <span>b</span></sml:p></content>`,
|
||||
want: "<content xmlns:sml=\"urn:x\">\n <sml:p><span>a</span> <span>b</span></sml:p>\n</content>\n",
|
||||
},
|
||||
{
|
||||
name: "already formatted input is preserved",
|
||||
input: "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n",
|
||||
want: "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
wantSecond := tt.wantSecond
|
||||
if wantSecond == "" {
|
||||
wantSecond = tt.want
|
||||
}
|
||||
again, err := prettyPrintXML(got)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(second pass, %q): %v", got, err)
|
||||
}
|
||||
if again != wantSecond {
|
||||
t.Fatalf("second pass:\nonce: %q\ntwice: %q\nwant: %q", got, again, wantSecond)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLPreservesLexicalFormsEtreeChanged pins the cases where
|
||||
// slicing original bytes intentionally differs from the previous
|
||||
// etree-based parse-and-reserialize implementation. Each case preserves the
|
||||
// input MORE faithfully than before; none is covered by the original
|
||||
// contract tests. The etree field records the old output for the record.
|
||||
func TestPrettyPrintXMLPreservesLexicalFormsEtreeChanged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string // current behavior: original bytes preserved
|
||||
etree string // what the etree-based implementation produced
|
||||
}{
|
||||
{
|
||||
name: "whitespace-only CDATA between structural children is kept",
|
||||
input: `<data><![CDATA[ ]]><child/></data>`,
|
||||
want: "<data><![CDATA[ ]]>\n <child/>\n</data>\n",
|
||||
etree: "<data>\n <child/>\n</data>\n",
|
||||
},
|
||||
{
|
||||
name: "empty element with explicit close tag is not collapsed",
|
||||
input: `<slide><data></data><shape/></slide>`,
|
||||
want: "<slide>\n <data></data>\n <shape/>\n</slide>\n",
|
||||
etree: "<slide>\n <data/>\n <shape/>\n</slide>\n",
|
||||
},
|
||||
{
|
||||
name: "non-whitespace character reference keeps its lexical form",
|
||||
input: `<presentation><title>A&中</title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title>A&中</title>\n <slide/>\n</presentation>\n",
|
||||
etree: "<presentation>\n <title>A&中</title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "single-quoted attributes keep their quoting",
|
||||
input: `<presentation><slide id='s1'><shape/></slide></presentation>`,
|
||||
want: "<presentation>\n <slide id='s1'>\n <shape/>\n </slide>\n</presentation>\n",
|
||||
etree: "<presentation>\n <slide id=\"s1\">\n <shape/>\n </slide>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "in-tag whitespace is preserved verbatim",
|
||||
input: "<presentation><slide id=\"s1\" ><shape/></slide ></presentation>",
|
||||
want: "<presentation>\n <slide id=\"s1\" >\n <shape/>\n </slide >\n</presentation>\n",
|
||||
etree: "<presentation>\n <slide id=\"s1\">\n <shape/>\n </slide>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "literal > in leaf text is not re-escaped",
|
||||
input: `<presentation><title>a>b</title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title>a>b</title>\n <slide/>\n</presentation>\n",
|
||||
etree: "<presentation>\n <title>a>b</title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
if tt.want == tt.etree {
|
||||
t.Fatalf("case is not a divergence: want == etree == %q", tt.want)
|
||||
}
|
||||
again, err := prettyPrintXML(got)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(second pass, %q): %v", got, err)
|
||||
}
|
||||
if again != got {
|
||||
t.Fatalf("not idempotent:\nonce: %q\ntwice: %q", got, again)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// loadChartDemo reads the real-world chart demo shipped with the
|
||||
// lark-slides skill (~60KB, pretty-printed): the closest in-repo stand-in
|
||||
// for a full presentation read.
|
||||
func loadChartDemo(t testing.TB) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("../../skills/lark-slides/references/slides_chart_demo.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("read chart demo fixture: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// minifyXML strips whitespace-only text children of structural (non
|
||||
// text-bearing, element-bearing) elements — the exact text nodes
|
||||
// prettyPrintXML treats as disposable formatting — producing the
|
||||
// single-line element shape the slides server actually returns.
|
||||
// Document-level tokens (prolog, trailing newline) pass through verbatim,
|
||||
// because the formatter preserves them verbatim too.
|
||||
func minifyXML(t testing.TB, input string) string {
|
||||
t.Helper()
|
||||
tokens, err := tokenize(input)
|
||||
if err != nil {
|
||||
t.Fatalf("tokenize for minify: %v", err)
|
||||
}
|
||||
var out strings.Builder
|
||||
var emitElement func(startIndex int)
|
||||
emitElement = func(startIndex int) {
|
||||
start := tokens[startIndex]
|
||||
end := tokens[start.match]
|
||||
if textBearingTags[start.local] || !hasElementChild(tokens, startIndex) {
|
||||
out.WriteString(input[start.start:end.end])
|
||||
return
|
||||
}
|
||||
out.WriteString(input[start.start:start.end])
|
||||
for i := startIndex + 1; i < start.match; {
|
||||
child := tokens[i]
|
||||
switch child.kind {
|
||||
case tokenCharData:
|
||||
if !isAllWhitespace(input[child.start:child.end]) {
|
||||
out.WriteString(input[child.start:child.end])
|
||||
}
|
||||
i++
|
||||
case tokenStartElement:
|
||||
emitElement(i)
|
||||
i = child.match + 1
|
||||
default:
|
||||
out.WriteString(input[child.start:child.end])
|
||||
i++
|
||||
}
|
||||
}
|
||||
out.WriteString(input[end.start:end.end])
|
||||
}
|
||||
for i := 0; i < len(tokens); {
|
||||
token := tokens[i]
|
||||
if token.kind == tokenStartElement {
|
||||
emitElement(i)
|
||||
i = token.match + 1
|
||||
continue
|
||||
}
|
||||
out.WriteString(input[token.start:token.end])
|
||||
i++
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLChartDemoFixture formats the real chart demo both as
|
||||
// shipped (pretty-printed) and minified to the single-line shape the server
|
||||
// returns; both must converge on the same idempotent output.
|
||||
func TestPrettyPrintXMLChartDemoFixture(t *testing.T) {
|
||||
original := loadChartDemo(t)
|
||||
|
||||
formattedOriginal, err := prettyPrintXML(original)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(original): %v", err)
|
||||
}
|
||||
twice, err := prettyPrintXML(formattedOriginal)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(second pass): %v", err)
|
||||
}
|
||||
if twice != formattedOriginal {
|
||||
t.Fatal("prettyPrintXML is not idempotent on the chart demo fixture")
|
||||
}
|
||||
|
||||
minified := minifyXML(t, original)
|
||||
if strings.Contains(minified, ">\n <") {
|
||||
t.Fatalf("minified fixture still contains structural indentation: %q", minified[:200])
|
||||
}
|
||||
// Only the doc-level newline after the XML declaration and the trailing
|
||||
// newline may remain; the whole element tree must be one line.
|
||||
if got := strings.Count(minified, "\n"); got > 2 {
|
||||
t.Fatalf("minified fixture has %d newlines, want <= 2", got)
|
||||
}
|
||||
formattedMinified, err := prettyPrintXML(minified)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(minified): %v", err)
|
||||
}
|
||||
// Formatting drops exactly the whitespace minification dropped, so both
|
||||
// paths must converge on the same output.
|
||||
if formattedMinified != formattedOriginal {
|
||||
t.Fatal("format(minified) != format(original) for the chart demo fixture")
|
||||
}
|
||||
if !strings.Contains(formattedMinified, "\n <slide>") {
|
||||
t.Fatal("formatted chart demo lacks expected slide indentation")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPrettyPrintXMLChartDemoMinified(b *testing.B) {
|
||||
minified := minifyXML(b, loadChartDemo(b))
|
||||
b.SetBytes(int64(len(minified)))
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := prettyPrintXML(minified); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPrettyPrintXMLChartDemoPreformatted(b *testing.B) {
|
||||
original := loadChartDemo(b)
|
||||
b.SetBytes(int64(len(original)))
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := prettyPrintXML(original); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -101,40 +100,6 @@ func extractTaskGuid(input string) string {
|
||||
return extractTasklistGuid(input)
|
||||
}
|
||||
|
||||
var taskDisplayNumberPattern = regexp.MustCompile(`^t[0-9]+$`)
|
||||
|
||||
func parseTaskGUID(input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
invalid := func(format string, args ...interface{}) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...).
|
||||
WithParam("--task-id").
|
||||
WithHint("provide the Task OpenAPI GUID or a task applink containing guid=")
|
||||
}
|
||||
|
||||
if input == "" {
|
||||
return "", invalid("task ID is empty")
|
||||
}
|
||||
|
||||
lowerInput := strings.ToLower(input)
|
||||
if strings.HasPrefix(lowerInput, "http://") || strings.HasPrefix(lowerInput, "https://") {
|
||||
u, err := url.Parse(input)
|
||||
if err != nil {
|
||||
return "", invalid("invalid task applink: %v", err).WithCause(err)
|
||||
}
|
||||
guid := strings.TrimSpace(u.Query().Get("guid"))
|
||||
if guid == "" {
|
||||
return "", invalid("task applink is missing a non-empty guid query parameter")
|
||||
}
|
||||
return guid, nil
|
||||
}
|
||||
|
||||
if taskDisplayNumberPattern.MatchString(input) {
|
||||
return "", invalid("task display number %q is not a Task OpenAPI GUID", input)
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := make(map[string]interface{})
|
||||
|
||||
|
||||
@@ -4,11 +4,8 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
@@ -18,80 +15,3 @@ func TestShortcutsRegistration(t *testing.T) {
|
||||
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseTaskGUID(t *testing.T) {
|
||||
t.Run("accepts GUIDs and task applinks", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "opaque GUID", input: "task-guid-123", want: "task-guid-123"},
|
||||
{name: "trimmed GUID", input: " task-guid-123 ", want: "task-guid-123"},
|
||||
{
|
||||
name: "task applink",
|
||||
input: "https://applink.larksuite.com/client/todo/detail?guid=task-guid-123",
|
||||
want: "task-guid-123",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseTaskGUID(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTaskGUID(%q) error = %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("parseTaskGUID(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects unusable task identifiers", func(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"",
|
||||
"https://applink.larksuite.com/client/todo/detail",
|
||||
"https://%",
|
||||
"t12345",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, err := parseTaskGUID(input)
|
||||
if err == nil {
|
||||
t.Fatalf("parseTaskGUID(%q) error = nil, want typed validation error", input)
|
||||
}
|
||||
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("parseTaskGUID(%q) error type = %T, want typed error", input, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatal("problem hint is empty")
|
||||
}
|
||||
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != "--task-id" {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, "--task-id")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves applink parse cause", func(t *testing.T) {
|
||||
_, err := parseTaskGUID("https://%")
|
||||
if err == nil {
|
||||
t.Fatal("parseTaskGUID() error = nil, want URL parse error")
|
||||
}
|
||||
|
||||
var urlErr *url.Error
|
||||
if !errors.As(err, &urlErr) {
|
||||
t.Fatalf("error chain = %T %v, want *url.Error cause", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,59 +25,45 @@ var CompleteTask = common.Shortcut{
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task GUID or task applink URL", Required: true},
|
||||
},
|
||||
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := parseTaskGUID(runtime.Str("task-id"))
|
||||
return err
|
||||
{Name: "task-id", Desc: "task id", Required: true},
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildCompleteBody()
|
||||
taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
taskID := url.PathEscape(taskGUID)
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/task/v2/tasks/" + taskID).
|
||||
GET("/open-apis/task/v2/tasks/" + taskId).
|
||||
Desc("get current task status").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
PATCH("/open-apis/task/v2/tasks/" + taskID).
|
||||
PATCH("/open-apis/task/v2/tasks/" + taskId).
|
||||
Desc("complete task if not completed").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskID := url.PathEscape(taskGUID)
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
var data map[string]interface{}
|
||||
|
||||
// 1. Get current task status
|
||||
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskID, params, nil)
|
||||
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
taskData, _ := getData["task"].(map[string]interface{})
|
||||
completedAtStr, _ := taskData["completed_at"].(string)
|
||||
alreadyCompleted := completedAtStr != "" && completedAtStr != "0"
|
||||
|
||||
// 2. If already completed, directly return success
|
||||
if alreadyCompleted {
|
||||
if completedAtStr != "" && completedAtStr != "0" {
|
||||
data = getData
|
||||
} else {
|
||||
// 3. Complete the task
|
||||
body := buildCompleteBody()
|
||||
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskID, params, body)
|
||||
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,19 +73,11 @@ var CompleteTask = common.Shortcut{
|
||||
guid, _ := task["guid"].(string)
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
completedAt, _ := task["completed_at"].(string)
|
||||
status := "todo"
|
||||
if completedAt != "" && completedAt != "0" {
|
||||
status = "done"
|
||||
}
|
||||
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
"status": status,
|
||||
"completed_at": completedAt,
|
||||
"already_completed": alreadyCompleted,
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
|
||||
@@ -4,12 +4,9 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
@@ -48,9 +45,6 @@ func TestCompleteTask(t *testing.T) {
|
||||
formatFlag: "json",
|
||||
expectedOutput: []string{
|
||||
`"guid": "task-789"`,
|
||||
`"status": "done"`,
|
||||
`"completed_at": "1775174400000"`,
|
||||
`"already_completed": false`,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -115,98 +109,3 @@ func TestCompleteTask(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCompleteAcceptsTaskApplink(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
for _, method := range []string{"GET", "PATCH"} {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: method,
|
||||
URL: "/open-apis/task/v2/tasks/task-guid-applink",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-guid-applink",
|
||||
"summary": "Applink task",
|
||||
"completed_at": map[string]string{"GET": "0", "PATCH": "1775174400000"}[method],
|
||||
"url": "https://example.com/task-guid-applink",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := runMountedTaskShortcut(t, CompleteTask, []string{
|
||||
"+complete",
|
||||
"--task-id", "https://applink.larksuite.com/client/todo/detail?guid=task-guid-applink",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteTask error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
if !strings.Contains(stdout.String(), `"guid": "task-guid-applink"`) {
|
||||
t.Fatalf("output = %s, want normalized task GUID", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCompleteAlreadyCompletedReturnsServerState(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks/task-guid-done",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-guid-done",
|
||||
"summary": "Already done",
|
||||
"completed_at": "1775174400000",
|
||||
"url": "https://example.com/task-guid-done",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runMountedTaskShortcut(t, CompleteTask, []string{
|
||||
"+complete", "--task-id", "task-guid-done", "--format", "json", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteTask error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data["status"] != "done" || data["completed_at"] != "1775174400000" || data["already_completed"] != true {
|
||||
t.Fatalf("completion state = %#v, want done/already_completed server state", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCompleteRejectsDisplayNumberBeforeRead(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
err := runMountedTaskShortcut(t, CompleteTask, []string{
|
||||
"+complete", "--task-id", "t12345", "--format", "json", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("CompleteTask error = nil, want invalid task ID error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
|
||||
t.Fatalf("error param = %#v, want --task-id", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,14 +24,6 @@ func splitAndTrimCSV(input string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func buildSearchPageParams(pageToken string) map[string]interface{} {
|
||||
params := map[string]interface{}{}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func parseTimeRangeMillis(input string) (string, string, error) {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return "", "", nil
|
||||
|
||||
@@ -37,31 +37,6 @@ func TestSplitAndTrimCSV(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchPageParams(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pageToken string
|
||||
wantToken string
|
||||
wantKey bool
|
||||
}{
|
||||
{name: "first page omits token"},
|
||||
{name: "subsequent page includes token", pageToken: "pt_123", wantToken: "pt_123", wantKey: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
params := buildSearchPageParams(tt.pageToken)
|
||||
got, present := params["page_token"]
|
||||
if present != tt.wantKey {
|
||||
t.Fatalf("page_token present = %v, want %v; params = %#v", present, tt.wantKey, params)
|
||||
}
|
||||
if tt.wantKey && got != tt.wantToken {
|
||||
t.Fatalf("page_token = %v, want %q", got, tt.wantToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputTaskSummary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -44,10 +44,8 @@ var SearchTask = common.Shortcut{
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
params := buildSearchPageParams(runtime.Str("page-token"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasks/search").
|
||||
Params(params).
|
||||
Body(body).
|
||||
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
|
||||
},
|
||||
@@ -76,9 +74,9 @@ var SearchTask = common.Shortcut{
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
var notice string
|
||||
params := buildSearchPageParams(runtime.Str("page-token"))
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", params, body)
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -92,7 +90,7 @@ var SearchTask = common.Shortcut{
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
params["page_token"] = lastPageToken
|
||||
currentBody["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
enriched := make([]map[string]interface{}, 0, len(rawItems))
|
||||
@@ -185,6 +183,9 @@ func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}
|
||||
if len(filter) > 0 {
|
||||
body["filter"] = filter
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
body["page_token"] = pageToken
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestSearchPaginationUsesQueryToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
command string
|
||||
url string
|
||||
}{
|
||||
{
|
||||
name: "tasks",
|
||||
shortcut: SearchTask,
|
||||
command: "+search",
|
||||
url: "/open-apis/task/v2/tasks/search",
|
||||
},
|
||||
{
|
||||
name: "tasklists",
|
||||
shortcut: SearchTasklist,
|
||||
command: "+tasklist-search",
|
||||
url: "/open-apis/task/v2/tasklists/search",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
var pageTokens []string
|
||||
reg.Register(searchPaginationStub(t, tt.url, "next_pt", true, &pageTokens))
|
||||
reg.Register(searchPaginationStub(t, tt.url, "", false, &pageTokens))
|
||||
|
||||
shortcut := tt.shortcut
|
||||
shortcut.AuthTypes = []string{"bot", "user"}
|
||||
err := runMountedTaskShortcut(t, shortcut, []string{
|
||||
tt.command,
|
||||
"--query", "pagination",
|
||||
"--page-token", "initial_pt",
|
||||
"--page-limit", "2",
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("search command failed: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"initial_pt", "next_pt"}
|
||||
if !reflect.DeepEqual(pageTokens, want) {
|
||||
t.Fatalf("search page tokens = %#v, want %#v", pageTokens, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSearchDryRunPageToken(t *testing.T, preview *common.DryRunAPI, want string) {
|
||||
t.Helper()
|
||||
|
||||
data, err := preview.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal search dry-run preview: %v", err)
|
||||
}
|
||||
var envelope struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &envelope); err != nil {
|
||||
t.Fatalf("decode search dry-run preview: %v", err)
|
||||
}
|
||||
if len(envelope.API) != 1 {
|
||||
t.Fatalf("search dry-run API call count = %d, want 1; preview = %s", len(envelope.API), data)
|
||||
}
|
||||
call := envelope.API[0]
|
||||
if got, _ := call.Params["page_token"].(string); got != want {
|
||||
t.Fatalf("search dry-run params.page_token = %q, want %q; preview = %s", got, want, data)
|
||||
}
|
||||
if _, present := call.Body["page_token"]; present {
|
||||
t.Fatalf("search dry-run body unexpectedly contains page_token; preview = %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func searchPaginationStub(t *testing.T, endpoint, responseToken string, hasMore bool, capturedTokens *[]string) *httpmock.Stub {
|
||||
t.Helper()
|
||||
return &httpmock.Stub{
|
||||
Method: http.MethodPost,
|
||||
URL: endpoint,
|
||||
OnMatch: func(req *http.Request) {
|
||||
*capturedTokens = append(*capturedTokens, req.URL.Query().Get("page_token"))
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read search request body: %v", err)
|
||||
return
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Errorf("decode search request body: %v", err)
|
||||
return
|
||||
}
|
||||
if _, present := payload["page_token"]; present {
|
||||
t.Errorf("search request body unexpectedly contains page_token: %s", body)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": hasMore,
|
||||
"page_token": responseToken,
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -37,12 +37,9 @@ func TestBuildTaskSearchBody(t *testing.T) {
|
||||
check: func(t *testing.T, body map[string]interface{}) {
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
dueTime := filter["due_time"].(map[string]interface{})
|
||||
if body["query"] != "release" {
|
||||
if body["query"] != "release" || body["page_token"] != "pt_123" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
if _, present := body["page_token"]; present {
|
||||
t.Fatalf("body unexpectedly contains page_token: %#v", body)
|
||||
}
|
||||
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
}
|
||||
@@ -107,10 +104,9 @@ func TestBuildTaskSearchBody(t *testing.T) {
|
||||
|
||||
func TestSearchTask_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantPageToken string
|
||||
wantParts []string
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "valid dry run",
|
||||
@@ -118,8 +114,7 @@ func TestSearchTask_DryRun(t *testing.T) {
|
||||
_ = cmd.Flags().Set("query", "demo")
|
||||
_ = cmd.Flags().Set("page-token", "pt_demo")
|
||||
},
|
||||
wantPageToken: "pt_demo",
|
||||
wantParts: []string{`"query":"demo"`},
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
|
||||
},
|
||||
{
|
||||
name: "dry run error on invalid due",
|
||||
@@ -148,11 +143,7 @@ func TestSearchTask_DryRun(t *testing.T) {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
preview := SearchTask.DryRun(nil, runtime)
|
||||
if tt.wantPageToken != "" {
|
||||
assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
|
||||
}
|
||||
out := preview.Format()
|
||||
out := SearchTask.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
|
||||
@@ -41,10 +41,8 @@ var SearchTasklist = common.Shortcut{
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
params := buildSearchPageParams(runtime.Str("page-token"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasklists/search").
|
||||
Params(params).
|
||||
Body(body).
|
||||
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
|
||||
},
|
||||
@@ -73,9 +71,9 @@ var SearchTasklist = common.Shortcut{
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
var notice string
|
||||
params := buildSearchPageParams(runtime.Str("page-token"))
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", params, body)
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", nil, currentBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -89,7 +87,7 @@ var SearchTasklist = common.Shortcut{
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
params["page_token"] = lastPageToken
|
||||
currentBody["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
tasklists := make([]map[string]interface{}, 0, len(rawItems))
|
||||
@@ -172,6 +170,9 @@ func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interfa
|
||||
if len(filter) > 0 {
|
||||
body["filter"] = filter
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
body["page_token"] = pageToken
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ func TestBuildTasklistSearchBody(t *testing.T) {
|
||||
check: func(t *testing.T, body map[string]interface{}) {
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
createTime := filter["create_time"].(map[string]interface{})
|
||||
if _, present := body["page_token"]; present {
|
||||
t.Fatalf("body unexpectedly contains page_token: %#v", body)
|
||||
if body["page_token"] != "pt_tl" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
if filter["user_id"].([]string)[0] != "ou_creator" {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
@@ -80,10 +80,9 @@ func TestBuildTasklistSearchBody(t *testing.T) {
|
||||
|
||||
func TestSearchTasklist_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantPageToken string
|
||||
wantParts []string
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "valid dry run",
|
||||
@@ -91,8 +90,7 @@ func TestSearchTasklist_DryRun(t *testing.T) {
|
||||
_ = cmd.Flags().Set("query", "Q2")
|
||||
_ = cmd.Flags().Set("page-token", "pt_tl")
|
||||
},
|
||||
wantPageToken: "pt_tl",
|
||||
wantParts: []string{`"query":"Q2"`},
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
|
||||
},
|
||||
{
|
||||
name: "dry run error on invalid create time",
|
||||
@@ -118,11 +116,7 @@ func TestSearchTasklist_DryRun(t *testing.T) {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
preview := SearchTasklist.DryRun(nil, runtime)
|
||||
if tt.wantPageToken != "" {
|
||||
assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
|
||||
}
|
||||
out := preview.Format()
|
||||
out := SearchTasklist.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
|
||||
@@ -27,42 +27,27 @@ var UpdateTask = common.Shortcut{
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task GUID or task applink URL (comma-separated for multiple)", Required: true},
|
||||
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
|
||||
{Name: "summary", Desc: "task title"},
|
||||
{Name: "description", Desc: "task description"},
|
||||
{Name: "due", Desc: "due date (ISO 8601 / date:YYYY-MM-DD / relative:+2d / ms timestamp)"},
|
||||
{Name: "data", Desc: "JSON payload for task object"},
|
||||
},
|
||||
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := parseTaskGUIDs(runtime.Str("task-id"))
|
||||
return err
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildTaskUpdateBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
preview := common.NewDryRunAPI()
|
||||
for _, taskID := range taskIDs {
|
||||
preview.PATCH("/open-apis/task/v2/tasks/" + url.PathEscape(taskID)).
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
}
|
||||
return preview
|
||||
taskIds := strings.Split(runtime.Str("task-id"), ",")
|
||||
taskId := url.PathEscape(strings.TrimSpace(taskIds[0]))
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("/open-apis/task/v2/tasks/" + taskId).
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := buildTaskUpdateBody(runtime)
|
||||
if err != nil {
|
||||
// buildTaskUpdateBody already returns a typed validation error;
|
||||
@@ -70,11 +55,17 @@ var UpdateTask = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
taskIds := strings.Split(runtime.Str("task-id"), ",")
|
||||
var updatedTasks []map[string]interface{}
|
||||
|
||||
for _, taskID := range taskIDs {
|
||||
for _, taskId := range taskIds {
|
||||
taskId = strings.TrimSpace(taskId)
|
||||
if taskId == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID), params, body)
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId), params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -85,28 +76,19 @@ var UpdateTask = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
updateFields, _ := body["update_fields"].([]string)
|
||||
var tasks []map[string]interface{}
|
||||
for _, task := range updatedTasks {
|
||||
guid, _ := task["guid"].(string)
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
confirmed := make(map[string]interface{})
|
||||
for _, field := range updateFields {
|
||||
if value, ok := task[field]; ok {
|
||||
confirmed[field] = value
|
||||
}
|
||||
}
|
||||
tasks = append(tasks, map[string]interface{}{
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
"confirmed": confirmed,
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
})
|
||||
}
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"updated_fields": updateFields,
|
||||
"tasks": tasks,
|
||||
"tasks": tasks,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(updatedTasks)}, func(w io.Writer) {
|
||||
@@ -130,26 +112,6 @@ var UpdateTask = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func parseTaskGUIDs(input string) ([]string, error) {
|
||||
parts := strings.Split(input, ",")
|
||||
taskGUIDs := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if strings.TrimSpace(part) == "" {
|
||||
continue
|
||||
}
|
||||
guid, err := parseTaskGUID(part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskGUIDs = append(taskGUIDs, guid)
|
||||
}
|
||||
if len(taskGUIDs) == 0 {
|
||||
_, err := parseTaskGUID("")
|
||||
return nil, err
|
||||
}
|
||||
return taskGUIDs, nil
|
||||
}
|
||||
|
||||
func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
taskObj := make(map[string]interface{})
|
||||
var updateFields []string
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestParseTaskGUIDs(t *testing.T) {
|
||||
got, err := parseTaskGUIDs(" task-guid-1, https://applink.larksuite.com/client/todo/detail?guid=task-guid-2 ")
|
||||
if err != nil {
|
||||
t.Fatalf("parseTaskGUIDs() error = %v", err)
|
||||
}
|
||||
want := []string{"task-guid-1", "task-guid-2"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parseTaskGUIDs() = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
_, err = parseTaskGUIDs("task-guid-1,t12345")
|
||||
if err == nil {
|
||||
t.Fatal("parseTaskGUIDs() error = nil, want invalid display-number error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateDryRunPreviewsEveryTaskID(t *testing.T) {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().String("task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2", "")
|
||||
cmd.Flags().String("summary", "updated", "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
cmd.Flags().String("due", "", "")
|
||||
cmd.Flags().String("data", "", "")
|
||||
|
||||
preview := UpdateTask.DryRun(context.Background(), &common.RuntimeContext{Cmd: cmd})
|
||||
payload, err := json.Marshal(preview)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run preview: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &got); err != nil {
|
||||
t.Fatalf("decode dry-run preview: %v", err)
|
||||
}
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("dry-run API calls = %d, want 2; payload: %s", len(got.API), payload)
|
||||
}
|
||||
|
||||
wantURLs := []string{
|
||||
"/open-apis/task/v2/tasks/task-guid-1",
|
||||
"/open-apis/task/v2/tasks/task-guid-2",
|
||||
}
|
||||
for i, call := range got.API {
|
||||
if call.Method != "PATCH" {
|
||||
t.Errorf("api[%d].method = %q, want PATCH", i, call.Method)
|
||||
}
|
||||
if call.URL != wantURLs[i] {
|
||||
t.Errorf("api[%d].url = %q, want %q", i, call.URL, wantURLs[i])
|
||||
}
|
||||
if !reflect.DeepEqual(call.Params, map[string]interface{}{"user_id_type": "open_id"}) {
|
||||
t.Errorf("api[%d].params = %#v", i, call.Params)
|
||||
}
|
||||
if !reflect.DeepEqual(call.Body, got.API[0].Body) {
|
||||
t.Errorf("api[%d].body = %#v, want same body as first call %#v", i, call.Body, got.API[0].Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateNormalizesAllIDsAndReturnsConfirmedFields(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
first := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/task/v2/tasks/task-guid-1",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-guid-1",
|
||||
"url": "https://example.com/task-guid-1",
|
||||
"summary": "server summary one",
|
||||
"description": "server description one",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
second := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/task/v2/tasks/task-guid-2",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-guid-2",
|
||||
"url": "https://example.com/task-guid-2",
|
||||
"summary": "server summary two",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(first)
|
||||
reg.Register(second)
|
||||
|
||||
err := runMountedTaskShortcut(t, UpdateTask, []string{
|
||||
"+update",
|
||||
"--task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2",
|
||||
"--summary", "requested summary",
|
||||
"--description", "requested description",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateTask error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, ok := envelope["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data = %#v, want object", envelope["data"])
|
||||
}
|
||||
if got := stringSlice(data["updated_fields"]); !reflect.DeepEqual(got, []string{"summary", "description"}) {
|
||||
t.Fatalf("updated_fields = %v, want [summary description]", got)
|
||||
}
|
||||
|
||||
tasks, ok := data["tasks"].([]interface{})
|
||||
if !ok || len(tasks) != 2 {
|
||||
t.Fatalf("tasks = %#v, want two tasks", data["tasks"])
|
||||
}
|
||||
firstTask := tasks[0].(map[string]interface{})
|
||||
if firstTask["guid"] != "task-guid-1" || firstTask["url"] != "https://example.com/task-guid-1" {
|
||||
t.Fatalf("first task identifiers = %#v", firstTask)
|
||||
}
|
||||
if got := firstTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
|
||||
"summary": "server summary one", "description": "server description one",
|
||||
}) {
|
||||
t.Fatalf("first confirmed = %#v", got)
|
||||
}
|
||||
|
||||
secondTask := tasks[1].(map[string]interface{})
|
||||
if got := secondTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
|
||||
"summary": "server summary two",
|
||||
}) {
|
||||
t.Fatalf("second confirmed = %#v; omitted server fields must not be echoed from the request", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateValidatesEveryIDBeforeFirstWrite(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
err := runMountedTaskShortcut(t, UpdateTask, []string{
|
||||
"+update",
|
||||
"--task-id", "task-guid-1,t12345",
|
||||
"--summary", "must not be written",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("UpdateTask error = nil, want invalid task ID error")
|
||||
}
|
||||
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
|
||||
t.Fatalf("error param = %#v, want --task-id", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func stringSlice(value interface{}) []string {
|
||||
items, _ := value.([]interface{})
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if str, ok := item.(string); ok {
|
||||
result = append(result, str)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
## 各命令
|
||||
|
||||
### +file-list
|
||||
列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`(MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20,范围 1..200)/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间(pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`。
|
||||
列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`(MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20)/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间(pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`。
|
||||
|
||||
```bash
|
||||
lark-cli apps +file-list --app-id app_xxx
|
||||
|
||||
@@ -29,7 +29,7 @@ metadata:
|
||||
## 使用边界
|
||||
|
||||
- Base 业务操作只使用 `lark-cli base +...` shortcut,不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`。
|
||||
- 执行 update 前必须先查当前 shortcut 的 `--help` 或对应 reference。若命令要求完整配置,首次请求必须基于可信的当前配置执行 read-modify-write:只修改用户明确指定的内容,保留其他仍适用的可写配置,并按命令要求的结构提交。若命令支持局部/delta update,按其契约提交最小合法 payload;不得以不完整请求试错补参。
|
||||
- 本轮 Base 不依赖 `lark-cli schema`。SKILL 只保留路由、风险和复杂 JSON/DSL;简单命令由命令自身的参数、tips 和错误恢复承接。
|
||||
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先转 `lark-cli drive +import --type bitable`,导入完成后再回到 Base 命令。
|
||||
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`;Base 文档只保留会影响 Base 路径选择的权限规则。
|
||||
|
||||
@@ -104,18 +104,19 @@ metadata:
|
||||
|
||||
## 写入前置规则
|
||||
|
||||
- 更新前先看命令说明:需要完整提交时,先读取并补齐当前配置,只改用户指定的内容,再按命令要求提交;支持局部修改时,按命令说明和 reference 提交最小合法 payload。
|
||||
- 优先用写入返回确认结果;返回信息不足或任务明确要求核验时,再读回。
|
||||
- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不作为普通记录写入目标。
|
||||
- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。
|
||||
- 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。
|
||||
- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。
|
||||
- 删除、角色更新、字段更新、表单提交(`+form-submit`)等高风险操作遵循 CLI 的 confirmation gate,必须带 `--yes`;目标不明确时先用 get/list 消歧。
|
||||
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate;目标不明确时先用 get/list 消歧。
|
||||
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
|
||||
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list` 或 `+field-search-options` 确认目标选项存在。
|
||||
|
||||
## 表单与视图细节
|
||||
|
||||
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。
|
||||
- `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
|
||||
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
通过表单分享链接填写并提交多维表格表单。仅支持分享模式(share_token),支持填写普通字段值和上传本地文件作为附件。
|
||||
|
||||
> **⚠️ 高风险写操作(high-risk-write):** 本命令会向表单写入并提交数据,属于高风险写操作,必须额外传递 `--yes` 进行确认,否则会返回 `confirmation_required` 错误并退出。当用户明确要求提交且目标表单无歧义时,直接附加 `--yes`,无需再次询问。
|
||||
|
||||
## 填写前必读:先获取表单详情
|
||||
|
||||
**在调用 `+form-submit` 之前,必须先使用 `+form-detail` 获取表单详情。** 原因如下:
|
||||
@@ -23,11 +21,10 @@ lark-cli base +form-detail --share-token <share_token>
|
||||
|
||||
# 2️⃣ 根据返回的 questions 列表,按 type 格式化值、检查 required、判断 filter 条件
|
||||
|
||||
# 3️⃣ 再提交(高风险写操作,必须带 --yes)
|
||||
# 3️⃣ 再提交
|
||||
lark-cli base +form-submit \
|
||||
--share-token <share_token> \
|
||||
--json '{"fields":{...}}' \
|
||||
--yes
|
||||
--json '{"fields":{...}}'
|
||||
```
|
||||
|
||||
`+form-detail` 的返回中要重点读取 `questions[].type`、`questions[].required`、题目 `filter` 和附件场景所需的 `data.base_token`。
|
||||
@@ -38,8 +35,7 @@ lark-cli base +form-submit \
|
||||
# 基本提交(填写普通字段)
|
||||
lark-cli base +form-submit \
|
||||
--share-token <share_token> \
|
||||
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}' \
|
||||
--yes
|
||||
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}'
|
||||
|
||||
# 带附件提交(需要额外提供 --base-token)
|
||||
lark-cli base +form-submit \
|
||||
@@ -51,17 +47,15 @@ lark-cli base +form-submit \
|
||||
"附件字段名": ["./report.pdf", "./photo.png"],
|
||||
"另一个附件字段": ["./doc.docx"]
|
||||
}
|
||||
}' \
|
||||
--yes
|
||||
}'
|
||||
|
||||
# 使用应用身份(bot)
|
||||
lark-cli base +form-submit \
|
||||
--share-token <share_token> \
|
||||
--json '{"fields":{...}}' \
|
||||
--as bot \
|
||||
--yes
|
||||
--as bot
|
||||
|
||||
# 预览 API 调用(不实际执行,dry-run 无需 --yes)
|
||||
# 预览 API 调用(不实际执行)
|
||||
lark-cli base +form-submit \
|
||||
--share-token <share_token> \
|
||||
--json '{"fields":{...}}' \
|
||||
@@ -75,7 +69,6 @@ lark-cli base +form-submit \
|
||||
| `--share-token <token>` | 是 | 表单分享 Token(必填),从表单分享链接中提取 |
|
||||
| `--base-token <token>` | 条件必填 | Base token;**当 `--json` 包含 `attachments` 时必须提供**,用于将附件上传到 Base Drive Media |
|
||||
| `--json <json>` | 是 | JSON 对象,包含 `"fields"`(普通字段值)和 `"attachments"`(附件上传),详见下方说明 |
|
||||
| `--yes` | 是 | 确认高风险写操作。本命令为 high-risk-write,不带 `--yes` 会返回 `confirmation_required` |
|
||||
| `--format` | 否 | 输出格式:json(默认)\| pretty \| table \| ndjson \| csv |
|
||||
| `--as` | 否 | 身份:user(默认)\| bot |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
@@ -145,8 +138,7 @@ https://www.example.com/share/base/form/shrbcvST8eZy0vk8zjVZ1CAXNye
|
||||
```bash
|
||||
lark-cli base +form-submit \
|
||||
--share-token shrbcvST8eZy0vk8zjVZ1CAXNye \
|
||||
--json '{"fields":{...}}' \
|
||||
--yes
|
||||
--json '{"fields":{...}}'
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
@@ -166,7 +158,6 @@ lark-cli base +form-submit \
|
||||
|
||||
## 提示
|
||||
|
||||
- **本命令为高风险写操作(high-risk-write),必须额外传递 `--yes` 确认**,否则返回 `confirmation_required` 并以非零码退出;`--dry-run` 预览除外
|
||||
- 本命令仅支持通过表单分享链接(share_token)提交,不支持通过 base_token + table_id + view_id 方式提交
|
||||
- **当 `--json` 包含 `attachments` 时,必须额外提供 `--base-token`**,因为附件上传到 Base Drive Media 需要指定目标 Base
|
||||
- 附件字段只需在 `--json.attachments` 中提供本地路径即可,CLI 自动完成校验、并行上传、Token 获取和合并写入
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: lark-doc
|
||||
description: "飞书云文档(Docx / Wiki)内容操作:读取、创建、编辑文档,插入或下载图片附件,以及操作思维笔记。用户提供文档 URL/token(包括 doubao.com 的 /docx/、/wiki/)时使用;按 URL 路径/token 而非域名路由。文档内嵌资源按读取参考中的统一规则分流。文档评论走 lark-drive;表格或 Base 内部数据操作不在本 skill。"
|
||||
version: 2.0.0
|
||||
description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -9,41 +10,75 @@ metadata:
|
||||
|
||||
# docs
|
||||
|
||||
## 场景与 Shortcut 路由
|
||||
**身份:文档操作默认使用 `--as user`。首次使用前执行 `lark-cli auth login`。**
|
||||
|
||||
**CRITICAL:先判断场景,再读取该场景的参考文件;不要在任务开始时一次性读取全部参考文件。每个文件只在首次进入对应阶段时读取一次。**
|
||||
```bash
|
||||
# 常用示例
|
||||
lark-cli docs +fetch --doc "文档URL或token;若 URL 存在 #share-... 锚点,优先使用锚点方式读取,不要全文拉取"
|
||||
lark-cli docs +create --content '<title>标题</title><p>内容</p>'
|
||||
lark-cli docs +update --doc "文档URL或token" --command append --content '<p>内容</p>'
|
||||
```
|
||||
|
||||
**身份:文档操作默认使用 `--as user`**
|
||||
## 前置条件 — 执行操作前必读
|
||||
|
||||
优先使用 `lark-cli docs +<verb>` Shortcut;思维笔记使用独立的 `mindnotes` 命令。
|
||||
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
||||
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
||||
2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和必读 [`lark-doc-style.md`](references/style/lark-doc-style.md)(写作原则:默认段落、按体裁、组件克制);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
|
||||
### 文档正文
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
|
||||
|
||||
- **读取 / 摘要 — [`+fetch`](references/lark-doc-fetch.md)**:先读参考再获取文档。只读或摘要默认用 `simple`;更新前定位用局部 `with-ids`;保真改写才读 `full`;带 `#share-...` 的选区链接按原 URL 传入。
|
||||
- **从零创作 — [`创建工作流`](references/lark-doc-create-workflow.md) → [`+create`](references/lark-doc-create.md)**:先完整执行创建工作流,**简单任务不是跳过的理由**;通过 Publish Gate 后再创建文档。
|
||||
- **导入 / 空文档 — [`+create`](references/lark-doc-create.md)**:仅创建空文档或原样导入用户提供的完整内容时,跳过创建工作流。
|
||||
- **编辑 / block 直达链接 — [`+update`](references/lark-doc-update.md)**:语义改写、润色、重组、补写或排版时先完整读取参考,按推荐流程 fetch 后局部更新;明确旧文本 → 新文本可直接 `str_replace`,但写后必须 fetch 验证;每次更新后重新获取最新 block ID;block 直达链接也按该参考生成。
|
||||
> **格式选择规则(全局):**
|
||||
> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML。
|
||||
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
||||
|
||||
### 辅助能力
|
||||
## 快速决策
|
||||
- 用户要**复制文档 / 创建文档副本 / 另存为副本**时,切到 [`lark-drive`](../lark-drive/SKILL.md),按其中的复制指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`。
|
||||
- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
|
||||
- block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids`
|
||||
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
|
||||
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
|
||||
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
|
||||
- 新增或更新画板时,按 [`lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 选型;Mermaid 可由主 Agent 直接插入,SVG / 复杂图 / 已有画板更新按其中流程隔离到 SubAgent
|
||||
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
|
||||
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
|
||||
- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
|
||||
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
|
||||
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
|
||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
|
||||
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
|
||||
- 用户需要统计文档的**总字数 / 总字符数**(word count / character count)时,先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
|
||||
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
|
||||
- 文档内容中出现嵌入的 `<sheet>`、`<bitable>` 或 `<cite file-type="sheets|bitable">` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身
|
||||
|
||||
- **临时文件、解析与统计 — [`+script`](references/lark-doc-script.md)**:创建名称唯一的临时 XML,直接解析文档 URL / token 或本地 XML / Markdown、将 Markdown 转为 XML,或统计文档总字数 / 总字符数。
|
||||
- **历史版本 — [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md)**:查询、回滚文档历史版本或检查回滚任务状态。
|
||||
| 标签 / 属性 | 提取字段 | 切到技能 |
|
||||
|-|-|-|
|
||||
| `<sheet token="..." sheet-id="...">` | `token` -> spreadsheet_token, `sheet-id` | [`lark-sheets`](../lark-sheets/SKILL.md) |
|
||||
| `<bitable token="..." table-id="...">` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) |
|
||||
| `<cite type="doc" file-type="sheets" token="..." sheet-id="...">` | 同 `<sheet>` | [`lark-sheets`](../lark-sheets/SKILL.md) |
|
||||
| `<cite type="doc" file-type="bitable" token="..." table-id="...">` | 同 `<bitable>` | [`lark-base`](../lark-base/SKILL.md) |
|
||||
| `<vc-transcribe-tab vc-node-id="...">` | `vc-node-id` -> note_id | [`lark-note`](../lark-note/SKILL.md):先 `note +detail --note-id <vc-node-id>` |
|
||||
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch` 读取 src-token 文档,定位 block |
|
||||
|
||||
### 资源、画板与思维笔记
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
- **插入本地素材 — [`+media-insert`](references/lark-doc-media-insert.md)**:在文末插入本地图片或文件。
|
||||
- **预览素材 — [`+media-preview`](references/lark-doc-media-preview.md)**:预览文档中的图片、附件或素材。
|
||||
- **下载素材 — [`+media-download`](references/lark-doc-media-download.md)**:下载文档中的图片、附件、素材或画板缩略图。
|
||||
- **Docx 封面 — [`+resource-download` / `+resource-update` / `+resource-delete`](references/lark-doc-resource-cover.md)**:下载、更新或删除 Docx 封面。
|
||||
- **画板 — [`画板工作流`](references/lark-doc-whiteboard.md)**:创建或更新画板时先读取工作流;更新已有画板必须复用现有 token,禁止新建空白画板;底层写入优先使用 [`whiteboard +update`](../lark-whiteboard/references/lark-whiteboard-update.md),`docs +whiteboard-update` 仅为别名。
|
||||
- **思维笔记 — `mindnotes`**:已有思维笔记走 [`思维笔记链路`](references/lark-doc-mindnote.md);新建思维笔记走 [`lark-doc-whiteboard`](references/lark-doc-whiteboard.md)。
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
### 认证与 Scope
|
||||
|
||||
先执行 `docs` / `mindnotes` Shortcut,不预读 [`lark-shared`](../lark-shared/SKILL.md) 或预跑 `auth status --verify`;仅遇到未认证、token / 身份或 scope 错误时读取该 Skill,修复后重试。认证、身份或 scope 管理请求则直接使用该 Skill。
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
|
||||
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
|
||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
|
||||
| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
||||
| [`+resource-download` / `+resource-update` / `+resource-delete`](references/lark-doc-resource-cover.md) | Download, update, or delete a Docx cover image resource with `--type cover` |
|
||||
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |
|
||||
|
||||
## 不在本 Skill 范围
|
||||
|
||||
- **Drive 文件级操作**:找文档、导入导出、云空间文件上传 / 下载 / 权限管理 → [`lark-drive`](../lark-drive/SKILL.md)。复制文档、创建副本或另存为副本时,按其指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`。
|
||||
- **文档评论**:添加、查看、回复评论或增删 reaction → [`lark-drive`](../lark-drive/SKILL.md)。
|
||||
- **文档内嵌资源下钻**:处理不在本 Skill 范围的内嵌资源(如电子表格或 Base 内部数据)时,统一读取 [`lark-doc-fetch.md`](references/lark-doc-fetch.md#处理文档内嵌资源) 的「处理文档内嵌资源」,提取 token / ID 后按该节切到对应 Skill 或命令;根 Skill 不重复维护标签分发表。
|
||||
- 文档评论管理 → [`lark-drive`](../lark-drive/SKILL.md)
|
||||
- 电子表格或 Base 的数据操作 → [`lark-sheets`](../lark-sheets/SKILL.md) / [`lark-base`](../lark-base/SKILL.md)
|
||||
- 云空间文件上传、下载、权限管理 → [`lark-drive`](../lark-drive/SKILL.md)
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# Genre Contract: Business Analysis / 商业分析 (`report.business_analysis`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;结论前置、具体、条件化;模型只用于改变比较或暴露约束,不用管理黑话代替判断 |
|
||||
| 内容逻辑 | 围绕一个具体决策,比较现状 / 不行动与真实替代项;用统一目标和口径评价价值、全周期成本、风险、约束与可实施性,给出推荐、暂缓或验证门及翻转条件 |
|
||||
| 事实 / 边界 | 事实、估算、假设、未知和外部依赖分开;数字标来源、时点、单位、口径和置信范围;利益相关方、不可货币化影响和权限边界显著;分析建议不等于批准或承诺 |
|
||||
| 错误 | 为预选方案找论据;无现状基准或真实替代项;口径不一却排名;单一 ROI / BCR / 评分替代平衡判断;套 SWOT;估算冒充事实;忽略全周期成本、依赖或分配影响;未获批写成已承诺;建议不回链证据 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
比较投资、资源、市场、产品、经营或供应选项并支持判断,但正文不要求具名决策者作出选择 / 批准,也不形成授权、资源拨付或执行承诺入口。`商业`、`市场分析`、`SWOT`单独只用于召回;回答研究问题走 [`research-report.md`](research-report.md),纯指标解读走 [`data-report.md`](data-report.md),命中上述 ask / 授权入口时走 Workplace Proposal,接口、不变量和实现取舍为主走 Technical RFC。
|
||||
|
||||
## 子类型
|
||||
|
||||
投资 / 资源配置;build-buy-partner 或 vendor;市场进入 / 扩张;产品 / 组合优先级;经营模式 / 流程;定价 / 商业模式;高不确定性的试点或阶段门。分析深度随金额、复杂度、不可逆性、影响范围和风险提高。
|
||||
|
||||
## 证据与方法
|
||||
|
||||
- 定义问题、目标、成功标准、范围、约束、决策 owner / 时点和现状 / 不行动基准;记录选项生成与排除理由。
|
||||
- 对每个可行选项用相同维度比较收益、全生命周期成本、时间、能力 / 依赖、风险、受影响方、不可货币化影响和可逆性。
|
||||
- 现状数据与预测分开;按需说明币种、价格时点、折现和估算方法。不得从官网标价推断销量、收入或份额。
|
||||
- 对可能翻转结论的假设做范围、情景或敏感性分析,并给 switching value、决策门或验证信号;评分模型须解释权重和证据,不能只报总分。
|
||||
- 缺目标、成功标准、基准或可行选项时只产出 decision frame / options discovery;关键估算用 `[成本区间待核]` 和验证计划,可能翻转结论且无法界定时标记 `blocked`。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
推荐与条件 → case for change / 目标 / 现状基准 → 选项生成、排除理由与同口径比较 → 关键假设、风险、情景与翻转条件 → 建议为何优于替代 → 阶段门、监测 / 学习计划与未决条件。把现状当真实选项,用区间和场景替代伪精确单点,显著说明谁获益、谁承担成本,以及什么新证据会改变建议。
|
||||
@@ -1,32 +0,0 @@
|
||||
# Genre Contract: Data Report / 数据报告 (`report.data_report`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;准确、可复算、少形容词;标题表达发现、对象和时点,并保留不确定性 |
|
||||
| 内容逻辑 | 先建立指标契约和可比基线,再回答发生了什么、为何重要、还不能断言什么;观测、解释假设与行动条件分开,限制紧邻相关结论 |
|
||||
| 事实 / 边界 | 核心指标标定义、单位、分子分母、总体 / 分群、时间窗、来源 / 版本、更新时间和修订状态;比较须同口径,估计须披露可得不确定性,敏感小群体须汇总、抑制或限制访问;数据图须标轴、单位、分母、时点和来源,并提供文字等价信息 |
|
||||
| 错误 | 只列数字;隐藏分母或口径变化;不可比数据排名;选择性窗口 / 分群;相关性当因果;图轴、单位或来源缺失;统计显著冒充效应大小或业务胜出;伪精确;限制藏在附录 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
解读已定义指标、趋势、分布、漏斗、监控、估计或实验观察值。`有数据`、`有数字`、`分析一下`单独不决定路由;研究问题、抽样和可推广性为主走 [`research-report.md`](research-report.md),比较商业选项走 [`business-analysis.md`](business-analysis.md),组织状态、偏差和下一步走 Workplace 周期报告。
|
||||
|
||||
## 子类型
|
||||
|
||||
- KPI / 经营表现与趋势;分群、cohort 与分布;漏斗 / 路径与监控异常。
|
||||
- A/B 或实验 readout;设计和推断不足时只能报告观察值,不宣布因果胜出。
|
||||
- 预测、估计、修订或统计简报;须标模型 / 假设、适用期和修订状态。
|
||||
|
||||
## 证据与方法
|
||||
|
||||
- 保留可复算的基数、过滤、聚合、估计区间和质量说明;比较前核对定义、总体、时间窗、分母和处理方法。
|
||||
- 按误解风险同时给绝对值、绝对变化、相对变化和长期基线;不用多余小数位制造精确感。
|
||||
- 覆盖、缺失、偏差、口径变化和修订若会改变解释,须与对应发现同处,并说明可能方向、规模和影响。
|
||||
- 描述性差异不得写成因果;解释标为待验证假设。统计显著性不等于效应大小、实际重要性或完整决策依据。
|
||||
- 缺定义、分母、时间或来源时使用 `[指标定义待核]`、`[分母待核]`,对应值不得进入结论;不可比数据分开展示。核心决策依赖的质量缺口无法关闭时标记 `blocked`。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
关键发现与决策限制 → 指标契约 / 数据质量 → 总览与基线 → 必要分维、分布和反例 → 可支持的解释与待验证假设 → 条件式行动 / 验证门 → 方法、修订和来源。每段按“观测 → 基线 / 背景 → 限制 → 含义”推进;复杂图同时给出文字结论和必要精确值,任何视觉不得成为唯一证据。
|
||||
@@ -1,38 +0,0 @@
|
||||
# Genre Contract: Email / 邮件 (`platform.email`)
|
||||
|
||||
## 核心定位(硬约束)
|
||||
|
||||
- 交付物是可复制到邮件客户端的邮件成稿,不代表已发送,也不执行收件人查询、邮件发送、草稿箱或邮箱管理;实际邮件操作切到 `lark-mail`。
|
||||
- `presentation_mode` 默认使用 `formal`;仅在用户明确要求其他表达方式且不违反组织规范或所选 content contract 时调整。全文禁止使用 emoji,包括主题、正文、列表标记、组件图标和装饰符号。
|
||||
- 默认只使用短段落、列表和普通链接等基础结构。只有已由用户说明、平台文档或可信配置确认目标平台完整支持飞书富文本时,才允许使用 `rich` 或 rich block;不得根据“邮件”“HTML 邮件”、飞书文档承载或平台名称自行推断支持。
|
||||
- 一封邮件只承担一个主要沟通任务;主题、首段、正文和行动请求围绕同一目的。不得编造发件人身份、收件人关系、事实、权限、承诺、截止时间、附件或已完成动作;缺失但必需的信息使用清楚的占位符。
|
||||
- 不使用封面或目录。即使已确认平台能力,表格、图片、`callout`、画板及其他 rich block 也只能在信息确有需要时使用,并确保复制、投递和接收后的语义完整。
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用户明确要“写邮件、邮件成稿、邮件草稿、邮件措辞、email、e-mail”,或要求起草回复、跟进、通知、邀约、外联邮件时使用,内容保存在哪里不影响本合同生效。
|
||||
|
||||
查看、搜索、发送、回复或管理邮箱中的真实邮件属于 `lark-mail` 操作;邮件系统说明、邮件数据分析、营销方案或把邮件作为信息来源时不触发本合同。若任务既要成稿又要实际发送,先按本合同形成并确认内容,再切到 `lark-mail` 执行发送。
|
||||
|
||||
## 邮件主任务
|
||||
|
||||
| 主任务 | 内容脊柱 |
|
||||
|-|-|
|
||||
| 请求 / 决策 | 目的或结论 → 必要背景 → 明确请求 / 选项 → 期望时间或下一步 |
|
||||
| 通知 / 同步 | 关键变化 → 影响范围 → 接收方需知 / 需做 → 时间点与联系入口 |
|
||||
| 回复 / 跟进 | 对应的前情 → 新信息或直接答复 → 未决事项 → 下一步 |
|
||||
| 邀约 / 外联 | 联系缘由 → 与收件人的相关性 → 具体提议 → 低成本回应方式 |
|
||||
| 致歉 / 问题沟通 | 承认影响 → 已确认事实 → 补救动作 → 后续安排与边界 |
|
||||
|
||||
## 成稿要求
|
||||
|
||||
- 成稿先给主题,再给正文;只有用户要求或材料明确时才列出收件人、抄送人等信封字段。主题准确表达对象、事项或所需行动,不使用标题党、空泛寒暄或无信息量的“重要通知”。
|
||||
- 称呼依据已知关系和语境选择;关系不明时使用稳妥中性的称呼或显式占位符,不擅自套用亲密、职级或性别称谓。
|
||||
- 首段尽快说明来意、结论或与既有线程的关系。背景只保留收件人理解、判断或行动所需的信息,不把完整报告、会议纪要或思考过程原样搬入邮件。
|
||||
- 行动请求写清需要谁在何时以何种方式完成什么;材料没有给出负责人或时间时,不自行补造。多个并列事项使用列表,优先让收件人能直接逐项回应。
|
||||
- 提及链接、附件或引用材料时说明其用途;未实际提供或上传的材料写成待补占位符,不声称“见附件”。回复和跟进邮件只补充新信息,不机械复述整个线程。
|
||||
- 结尾与邮件目的匹配:请求类明确回应方式,通知类说明无需动作或下一节点,外联类保留易于拒绝或调整的空间。署名仅使用已知身份;身份不明时使用占位符,不虚构姓名、团队或联系方式。
|
||||
|
||||
## 交付前检查
|
||||
|
||||
确认收件人能从主题和首段判断“为什么收到、需要知道或做什么”,事实、责任人、时间和附件状态均有依据,正文没有无关铺垫或重复,语气符合关系与风险,全文无 emoji;若使用 rich block,已有目标平台支持飞书富文本的确认依据;复制到邮件客户端后仍清晰可读,且未把“成稿”误写成“已发送”。
|
||||
@@ -1,27 +0,0 @@
|
||||
# Genre Contract: Execution Plan / 执行计划 (`workplace.execution_plan`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;以交付和判断为单位,具体、紧凑、可推进;计划可信度来自依赖、产能和验收闭环,不来自章节数量或精确到没有依据的日期 |
|
||||
| 内容逻辑 | 从已批准结果、成功标准、范围和约束出发,按“交付物 / 工作流 → 依赖与关键路径 → 带退出条件的里程碑 → owner / 接口 / 资源 → 风险触发与备选 → 治理、变更与验收”推进 |
|
||||
| 事实 / 边界 | 区分已确认承诺、估算、假设和待定项;时间、owner、预算、产能、权限、依赖与验收方须可追溯且算术相容;未批准方向不得写成承诺,关键资源或安全前提未知时收窄计划或 `blocked` |
|
||||
| 错误 | 任务清单冒充计划、活动无交付物 / 完成定义、里程碑只是日期、排期不服从依赖与产能、所有事项同优先级、接口或验收方缺失、风险无预警信号 / 动作 / owner、变更后不更新基线,任一出现即失败 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用于方向和目标已定后,组织一次性项目、迁移、发布、活动战役、专项治理或跨团队变更。主要任务仍是选择方向、申请预算 / 资源或授权时走 `proposal.md`;比较策略选项且不形成批准入口走 `business-analysis.md`;发布已授权规则 / 通知走 `formal-doc.md`;重复确定路径走 `sop-tutorial.md`;报当前状态走 `weekly-report.md`。
|
||||
|
||||
“项目计划、执行方案、实施计划、营销策划”只作召回词。营销策划若仍在决定打法或预算,按上述 Proposal / Business Analysis 消歧;只有已定打法的协同落地走本合同。
|
||||
|
||||
## 可执行性与证据
|
||||
|
||||
- 先写可验收结果、范围 / 非范围、约束和最迟决策点;再按交付物而非部门名称拆工作包。每个关键工作包说明 owner、输入 / 输出、依赖、完成定义和验收方。
|
||||
- 标出关键路径、可并行项、阶段入口 / 退出条件与资源瓶颈;日期由依赖、产能和必要审批 / 制作 / 校准时间推导。无法推导时用相对时间、区间或具体占位,不补造精确排期。
|
||||
- 风险写预警信号、影响、预防 / 响应动作、决策 owner 和备选路径;备选必须说明何时切换及切换后的安全或业务终态,不写“加强沟通”。
|
||||
- 治理只保留会产生判断的节奏:接口、升级条件、决策权、范围 / 基线变更和重新验收。密集对应关系可用一张排期、依赖或责任表,但表格不能替代关键路径和取舍说明。
|
||||
|
||||
## 高质量写法
|
||||
|
||||
让每个目标能一路回链到交付物、里程碑和工作包,让每个日期能回链依赖与产能,让每个风险能回链触发后的动作。资源不足时缩范围、分阶段或设决策门,不用“全渠道、全覆盖、同步推进”制造伪可行性。
|
||||
@@ -1,36 +0,0 @@
|
||||
# Genre Contract: Formal Document / 内部正式材料 (`workplace.formal_doc`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | 固定 `formal`;格式中立的内容稿完成后再应用。庄重、准确、简洁、直接;正式性来自真实权威、事实、边界、责任和生命周期,不来自套话、层级或装饰 |
|
||||
| 允许 block | `title`(完整文稿最多 1 个)、`p`、`h1`、`h2`、`h3`、`h4`;标题层级连续且不超过四级 |
|
||||
| 限用 block | `ul`、`ol`容器及`li`子块仅承载真实并列或顺序;`table`容器及`thead`、`tbody`、`tfoot`、`tr`子块仅承载多对象同字段信息;`img`、`figure`仅承载有必要证据作用、来源说明和文字等价信息的材料 |
|
||||
| 禁止 block | 禁止未列入允许 / 限用清单的类型,包括`callout`、`checkbox`、`grid`容器及`column`子块、`whiteboard`、`blockquote`、`pre`、根级`code`和`hr`;禁止装饰色、贴纸、伪红头、伪印章和无证据作用的配图 |
|
||||
| 内容逻辑 | 先按读者任务选择规则 / 制度、已批准通知 / 安排、检查整改 / 台账或已核定正式说明之一;只写完成该任务所需的对象、依据、要求 / 发现、责任、核验和生命周期,不混写子类型 |
|
||||
| 事实 / 边界 | 只把已确认的授权、要求、事实和立场写成定论;来源陈述、原始记录、已复核事实和推断分开;外发前确认保密、商业秘密、个人信息、素材权利和发布权限;关键缺口使 `Publish Gate = blocked` |
|
||||
| 错误 | 因“正式”误判公文,把本 leaf 当方案 / 总结 / 简报兜底,伪造批准 / 生效,用通知偷渡未获授权的新规则,网络素材冒充本单位事实,检查线索写成责任结论,或措施与发现不对应,任一出现即失败 |
|
||||
|
||||
## 适用与收口
|
||||
|
||||
用于把已授权的非公文组织规则或安排、可复核的检查整改记录,或已核定的组织立场写成正式载体,使读者能够判断适用范围、应采取的行动、记录状态或核心立场。
|
||||
|
||||
待批准方向走 `proposal.md`;复杂一次性执行走 `execution-plan.md`;重复操作步骤走 `sop-tutorial.md`;党政机关公文走 `official-redhead.md`;高层简报走 `memo-brief.md`;周期状态走 `weekly-report.md`;学习总结走 Retrospective / Report。`正式、制度、通知、方案、计划、总结、简报、讲话稿`等词单独不触发本体裁,本体裁也不是不确定请求的 fallback。
|
||||
|
||||
## 按读者任务选择唯一内容路径
|
||||
|
||||
| 读者任务 | 内容主线 |
|
||||
|-|-|
|
||||
| 判断持续规则 | 目的与权威 → 适用 / 不适用范围 → 必要定义 → 规范要求 → 责任、例外与升级 → 生效、维护、复审和替代 |
|
||||
| 执行已批准通知 | 发布主体与批准状态 → 受影响对象及范围 → 已确认事项与生效时间 → 动作、责任与期限 → 例外、反馈和联系人 |
|
||||
| 复核检查整改 | 对象、范围、方法与证据状态 → 每项可观察发现、标准、影响和已支持原因 → 对应措施、责任与期限 → 核验、关闭证据和变更痕迹 |
|
||||
| 理解已核定立场 | 讲者或发布主体、场合、受众与时长 → 核心立场 → 必要事实和理由 → 期望理解或行动;不混入制度效力 |
|
||||
|
||||
## 证据与高质量写法
|
||||
|
||||
- 规则类按需写维护责任、版本、批准、生效、复审和替代状态;稳定描述做什么、谁负责、何时生效,易变操作方法链接到受控 SOP。规范词优先沿用组织现有定义,强度不明时标`[规范强度待确认]`。
|
||||
- 检查整改区分用户陈述、原始记录、已复核事实和待补证线索;关键日期、数量或结论证据不足时就近标`[证据待补:补证动作]`,不得推断原因或责任;归档补正保留原记录。
|
||||
- 检查措施必须对应具体发现并可核验;已批准通知只传达授权范围内的事项;正式讲话只使用已核定立场,并按真实语速朗读校验。
|
||||
- 使用主动句、明确主体和一致术语,一句只表达一个事实、判断、要求或许可;清单严守用户指定数量与字段,不机械补背景或文控字段。
|
||||
- 批准者、依据、权限、适用范围、生效状态或发布条件不明时使用具体占位并保持草案;不得以版式、标题或署名暗示已经批准、签发或生效。
|
||||
@@ -1,24 +0,0 @@
|
||||
# Genre Contract: Meeting Minutes / 会议纪要 (`workplace.meeting_minutes`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;中性、精确、按议题和决定组织,用稳定标签区分决定、建议、未决和待确认,不重放发言顺序 |
|
||||
| 内容逻辑 | 先说明会议身份和记录状态,再按议题写实际材料 / 必要讨论摘要 → 决定及理由 / 异议 → 未决项 → 行动 → 审阅材料;深度与治理风险相称 |
|
||||
| 事实 / 边界 | 出席、法定人数、冲突、动议、表决、决定、owner、期限和批准状态均须来自会议材料或确认;只保留治理所需个人信息,草稿不得冒充批准版;历史状态固定为文字快照,不得由`checkbox`、`task`等可变交互块改写 |
|
||||
| 错误 | 摘要冒充逐字稿、讨论流水账、建议写成决定、行动不可跟踪、法定人数不明却宣称决定有效、草稿冒充批准、静默改历史或泄露无关个人信息,任一出现即失败 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用于某次已发生会议的可引用治理记录,使缺席者、执行者和审核者确认决定、未决与行动。逐字 / 逐发言人 / 可回放内容只是 transcript 源材料;会前准备走 `memo-brief.md`;非会议状态走 `weekly-report.md`;党政机关法定“纪要”走 `official-redhead.md`。
|
||||
|
||||
## 子类型与治理证据
|
||||
|
||||
普通工作会可精简为会议身份、决定、未决和行动;项目决策会补必要理由与审阅材料;董事会、委员会、表决或法定会议按章程 / 适用规则记录出席、法定人数、利益冲突、动议、票决、精确决议及认证。
|
||||
|
||||
证据可来自 agenda、出席记录、实际审阅材料、动议 / 投票和录音 / 逐字稿,但正文只链接关键来源,不复制附件淹没决定。来源冲突并列保留并交主持人 / 参会者确认。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
标明名称 / 类型、日期时间、地点 / 方式、主持 / 记录和草稿 / 已批准状态;每个议题围绕结果而非发言顺序。行动项写交付物 / 动作、责任人 / 单位、时间要求和状态。缺失信息用`[决议原文待确认]`、`[owner 待确认]`等具体占位;法定人数或批准不明时不得宣称有效,保持草稿并进入确认流程。
|
||||
@@ -1,25 +0,0 @@
|
||||
# Genre Contract: Memo / Brief (`workplace.memo_brief`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;直接、克制、按具名读者控制信息密度,首屏给结论、状态或 ask,不设固定篇幅 |
|
||||
| 内容逻辑 | 先选信息、决策或会前三种模式之一,再按“核心事项 / ask → 必要事实 → 影响 / 取舍 → 风险 / 未知 → 动作”推进;只有真实选择才写选项 |
|
||||
| 事实 / 边界 | 事实、数字、立场、审批状态和时点均须可核验;未知与假设就近标记;Memo 只可作完整 Proposal 的决策封面,不替代其论证 |
|
||||
| 错误 | 首屏无结论或 ask、把完整 Proposal 压成摘要、编造审批 / 立场、用固定篇幅删证据、细节不解释影响,任一出现即失败 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用于让具名内部读者快速知悉、判断或完成会前准备。请求批准完整方向、预算、资源或执行承诺走 `proposal.md`;按周期判断相对目标的位置走 `weekly-report.md`;“摘要 / 简报”单词本身不触发本体裁。
|
||||
|
||||
## 子类型与证据
|
||||
|
||||
- 信息 Brief:变化 → 影响 → 当前状态 / 风险 → 下一步;无须行动时明确“仅供知悉”。
|
||||
- 决策 Memo:决定事项 / 时点 → 现状 → 真实选项及同口径影响 → 推荐与证据 → 明确决策入口。
|
||||
- 会前 Brief:会议目标 → 已核验的参与方立场 / 利益 → 要点与禁区 → 期望结果;未知立场不得补造。
|
||||
- 按需标读者、作者 / 责任团队、日期和信息截至时间。持续更新时说明相对上版的变化及下次更新点。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
按重要性而非材料顺序组织,一个段落一个观点;关键判断不藏在附件。建议写清谁做什么、为什么以及怎样判断完成,并呈现足以改变判断的风险、反例和不确定性。缺关键事实时用`[关键结论待确认]`、`[数据口径待核]`等具体占位,或收窄为待核问题清单;仍要求据此批准时必须 `blocked`。
|
||||
@@ -1,72 +0,0 @@
|
||||
# Genre Contract: Official Document / 公文内容稿 (`workplace.official_redhead`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | 固定 `formal`;庄重、准确、简洁、直接。禁 emoji、网感、营销话术、情绪化评价、空话和机械编号 |
|
||||
| 允许 block | `title`(完整文稿最多 1 个)、`p`、`h1`、`h2`、`h3`、`h4`;标题层级连续且不超过四级 |
|
||||
| 少用 block | `ul`、`ol`容器及`li`子块仅用于真实并列项,不替代公文层级序号;`table`容器及`thead`、`tbody`、`tfoot`、`tr`子块仅用于非表格难以清楚表达的多对象同字段信息 |
|
||||
| 禁止 block | 禁止未列入允许 / 少用清单的类型,包括`callout`、`grid`容器及`column`子块、`checkbox`、`whiteboard`、`blockquote`、`pre`、根级`code`、`hr`、`img`、`figure`;禁装饰色、伪红头和伪印章 |
|
||||
| 内容逻辑 | 按行文目的、机关关系和受众确定唯一文种,再按“必要依据 / 缘由 → 核心事项 / 决定 → 可执行要求 → 必要结语”推进 |
|
||||
| 事实 / 边界 | 只写已给定或已核验的事实、依据、权限和决定;未知项具体占位,关键缺口使 `Publish Gate = blocked`;飞书只交付内容审校稿,不宣称已签发或生效 |
|
||||
| 错误 | 禁止文种或行文关系错误、报告夹请示、请示一文多事 / 多头主送、批复无对应请示、引用 / 文号 / 序号 / 附件不规范,以及编造事实、依据、权限或制发要素 |
|
||||
|
||||
## 适用
|
||||
|
||||
仅在明确要求公文、红头 / 套红、正式发文,或法定文种与机关行文关系、发文字号、签发人、主送机关等制发要素共同出现时使用。“红头文件”是制发信号,不是文种;普通公司通知、制度、检查 / 整改材料走 `formal-doc.md`,普通会议记录走 `meeting-minutes.md`。
|
||||
|
||||
## 文种选择
|
||||
|
||||
按“行文目的 → 发文与受文机关关系 → 受众范围”判断,不按单个关键词判断。
|
||||
|
||||
| 文种 | 适用意图 |
|
||||
|-|-|
|
||||
| 决议 | 会议讨论通过重大决策 |
|
||||
| 决定 | 对重要事项作出决策部署、奖惩或变更 / 撤销决定 |
|
||||
| 命令(令) | 公布法规规章、施行重大强制措施、授予衔级或嘉奖 |
|
||||
| 公报 | 权威公布重要决定或重大事项 |
|
||||
| 公告 | 向国内外宣布重要或法定事项 |
|
||||
| 通告 | 在一定范围公布应遵守或周知的事项 |
|
||||
| 意见 | 对重要问题提出见解和处理办法 |
|
||||
| 通知 | 要求下级 / 有关单位执行或周知,批转、转发公文 |
|
||||
| 通报 | 表彰、批评、传达重要精神或告知重要情况 |
|
||||
| 报告 | 向上级汇报工作、反映情况或答复询问,不请求决定 |
|
||||
| 请示 | 向上级请求指示或批准;一文一事,原则上只主送一个上级机关 |
|
||||
| 批复 | 答复下级机关请示,必须有对应来文 |
|
||||
| 议案 | 政府依法向同级人大或其常委会提请审议 |
|
||||
| 函 | 不相隶属机关间商洽、询答、请求批准或答复审批 |
|
||||
| 纪要 | 记载正式会议主要情况和议定事项,不写逐字过程 |
|
||||
|
||||
优先消歧:汇报且不求决定用报告,求上级决定用请示,不相隶属机关商洽用函;面向明确单位执行用通知,面向一定范围不特定对象遵守用通告,向国内外宣布重要 / 法定事项用公告,传达情况或评价用通报。
|
||||
|
||||
## 行文与事实
|
||||
|
||||
- 按隶属关系、职权和授权行文;一般不越级,特殊越级时同时抄送被越过机关。
|
||||
- 上行文原则上主送一个上级机关,不抄送下级;报告不得夹带请示。除直接交办外,不主送上级负责人个人。
|
||||
- 一份主文保持一个行文方向和授权状态;同一事项若既需向上请求批准又需向下要求执行,应拆分文稿或待批准后另行制发,附件不得偷渡尚未授权的执行要求。
|
||||
- 下行要求不得超出发文机关权限;涉及其他地区 / 部门职权时先协商。联合行文仅限必要且主体关系适当的情形。
|
||||
- 只把已确认的决定写成指令。措施按需写明主体、动作、对象、期限、标准和反馈去向;对不相隶属机关使用`商请`、`请予`、`函复`等匹配关系的措辞。
|
||||
- 缺少授权、关键依据、核心事实、适用范围或审批决定时不得发布;不得猜测文号、签发人、密级或紧急程度。
|
||||
|
||||
## 内容结构
|
||||
|
||||
- 标题一般使用“发文机关 + 事由 + 文种”,内含法规、规章或被印发文件名称时使用书名号。
|
||||
- 主送机关使用全称、规范简称或同类机关统称。附件说明与附件顺序、名称逐字一致;多个附件用阿拉伯数字编号,名称末尾不加标点。
|
||||
|
||||
| 文种 | 常用结构 |
|
||||
|-|-|
|
||||
| 通知 | 缘由 / 依据 → 事项 → 对象 / 时间 → 已确认要求 |
|
||||
| 请示 | 缘由 / 依据 → 单一请示事项与倾向意见 → `妥否,请批示` |
|
||||
| 批复 | 准确引用来文 → 明确意见 → 执行要求 → `此复` |
|
||||
| 函 | 事项 / 依据 → 商请或答复 → `请予函复` / `特此函复` |
|
||||
| 报告 | 情况 → 事实 / 成效 → 问题 → 后续安排 → `特此报告` |
|
||||
| 纪要 | 会议基本信息 → 主要情况 → 议定事项 / 责任 / 时限 |
|
||||
|
||||
## 文号、引用与序号
|
||||
|
||||
- 普通发文字号采用“机关代字 + 完整年份 + 顺序号”,如 `×政发〔2026〕8号`;年份用六角括号,顺序号不加“第”、不编虚位。命令(令)的令号可用 `第×号`。
|
||||
- 首次引用其他公文时写完整标题和文号:`《××机关关于印发〈××办法〉的通知》(×发〔2026〕8号)`。不只写文号,不用论文式参考文献编号。
|
||||
- 文件、法律法规名称使用书名号;直接引文使用中文双引号,内层用单引号。引文须核对原文、效力、制定机关和适用范围;无法核实则标记 `[引文待核]`。
|
||||
- 正文层级依次使用 `一、`、`(一)`、`1.`、`(1)`,不得写成 `1、`、`(一)、`,不得跳级;超过四级时重组内容。
|
||||
- 成文日期写为 `2026年7月13日`,月日不补零。标点和数字按 GB/T 15834、GB/T 15835 使用;全称及规范简称前后一致。
|
||||
@@ -1,25 +0,0 @@
|
||||
# Genre Contract: PRD / 产品需求 (`workplace.prd`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `rich`;具体、行为化、术语和状态一致;在有明确内容作用时用场景、状态流、表格、图示和其他 rich block 降低理解与验收成本,但不让视觉组件替代需求、证据或验收,不用固定大模板制造完整感 |
|
||||
| 内容逻辑 | 方向已定后按“用户问题 / 证据 → 目标 / 结果 → 范围 / 非目标 → 场景 → 行为需求 / 验收 → 异常 / 边界 → 适用质量约束 → 依赖 / 开放问题”推进 |
|
||||
| 事实 / 边界 | 用户需要、指标、研究、阈值、可行性、owner、状态和排期须可追溯;需求描述可观察结果,acceptance criteria 验结果;安全、隐私、无障碍等仅按实际风险和标准纳入 |
|
||||
| 错误 | 功能清单无用户问题、Proposal 论证吞没需求、范围 / 非目标缺失、需求暗藏实现、验收不可测、正常路径无异常、伪造研究 / 阈值 / 批准或机械填质量模板,任一出现即失败 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用于方向与投入原则已定后,让产品、设计、研发和测试就用户问题、范围、可观察行为与完成标准形成共识。是否立项 / 选择方向 / 批资源走 `proposal.md`;架构、接口和实现取舍走 `technical-doc.md`;已批准重复操作走 `sop-tutorial.md`。“需求 / 功能”单词本身不触发。
|
||||
|
||||
## 证据与需求写法
|
||||
|
||||
- 明确目标用户、任务情境、问题及研究 / 行为 / 支持证据;内部偏好和预设功能不冒充用户需要。
|
||||
- 产品目标连接可观测结果,指标标口径、来源和时间窗。未知目标值用`[目标值待产品 / 数据确认]`并给确认 owner / 时点,不编使用量或阈值。
|
||||
- 关键需求写成 actor + trigger / precondition + observable outcome + failure / edge;术语和状态一致。用户故事格式只是工具,不是章节配额。
|
||||
- 每个质量约束给可验证门槛或明确待确认项;不适用时不填模板。需求、验收 / 测试和来源保持追踪。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
先定范围、非目标、优先级、依赖、假设和开放问题,防止 scope creep;再按关键场景写正常、异常和边界行为。把大而不可测的需求拆到可验收粒度,不用“体验更好 / 性能高”等形容词。没有用户证据时收窄为假设和研究计划;关键合规 / 安全门缺失时 `blocked`,开放问题不得藏在脚注。
|
||||
@@ -1,24 +0,0 @@
|
||||
# Genre Contract: Proposal / 方案提案 (`workplace.proposal`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;结论前置、具体、可审议,主动呈现代价、反例与不确定性,不用宏大背景或伪精确制造可批准感 |
|
||||
| 内容逻辑 | 明确 decision / 决策者 / 时点,再按“改变理由与不行动基准 → 目标 → 真实选项同口径比较 → 推荐 → 资源 / 交付 → 风险 / 未知 → 决策入口”推进 |
|
||||
| 事实 / 边界 | 区分事实、估算、假设和未知;收益、成本、资源、用户证据、审批和排期须可追溯;进入执行决策才写治理 / 退出条件,未批准不得写成既有承诺 |
|
||||
| 错误 | 无决策者 / ask、无不行动基准、预设单一答案、选项口径不同、成本风险后置、未批先承诺、编造收益 / 审批或与 PRD 混写,任一出现即失败 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用于请求具名决策者批准、驳回或选择方向、预算、资源、试点或执行承诺。方向已定并定义产品行为 / 验收走 `prd.md`;短决策封面走 `memo-brief.md`;已批准安排的发布走 `formal-doc.md`。“方案”单词本身不触发本体裁。
|
||||
|
||||
## 子类型与证据
|
||||
|
||||
可用于概念 / 方向、投资 / 预算、资源申请、变更、试点 / 实验和执行承诺提案;深度随阶段、金额、风险和不可逆性裁剪。必须给 case for change、目标 / 成功标准、不行动或最小变化基准,以及足以判断的成本、收益、依赖、风险和敏感因素。
|
||||
|
||||
存在真实选择时纳入可行替代并以相同范围、时间和评价标准比较;没有真实替代时说明约束如何收敛,不能造假选项。不可量化影响可定性,但须说明原因及其决策影响。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
先把选择题写对,再论证推荐;显式记录被放弃选项和推荐代价。数字不足时使用范围、依据和验证计划,不补精确点估。进入执行决策时按需补 owner、里程碑、治理、衡量、退出 / 复盘;缺决策权、关键成本或安全合规依据时收窄为探索稿,仍要求批准则 `blocked`。
|
||||
@@ -1,32 +0,0 @@
|
||||
# Genre Contract: Research Report / 调研报告 (`report.research_report`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;证据驱动、校准、术语一致;摘要独立可读,语气强度不得超过证据强度 |
|
||||
| 内容逻辑 | 先确定研究问题与研究类型,再交付当前答案、证据强度和可推广边界;按问题或主题组织发现,解释、建议和验证计划必须回链发现 |
|
||||
| 事实 / 边界 | 区分原始事实或参与者陈述、分析推断、假设和建议;方法披露足以评估偏差;适用时确认委托、利益、同意、匿名或保密、敏感数据用途;研究材料须确认使用权、去标识、来源与说明,复杂视觉附文字等价信息;未知不补造 |
|
||||
| 错误 | 无明确问题;方法黑箱;资料摘要冒充发现;样本外推;醒目个案冒充模式;事实、解释和建议混写;相关写成因果;合规状态、授权或行业共识靠猜 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
以明确研究问题、研究设计或材料、发现和限制为主要交付。`调研`、`研究过`、`访谈`、`问卷`单独只用于召回;只解读既定指标走 [`data-report.md`](data-report.md),比较特定战略或资源选项走 [`business-analysis.md`](business-analysis.md),方法并非判断重点的问题框架综合走 [`white-paper.md`](white-paper.md)。
|
||||
|
||||
## 子类型
|
||||
|
||||
- **定量 / 定性 / 混合研究**:根据问题选择总体、抽样或招募、工具、采集和分析方法;不用一种方法的规范冒充全部研究标准。
|
||||
- **用户研究 / 项目或政策评估**:说明场景、参与者、干预或对象、成功标准、观察窗口和用途。
|
||||
- **证据综合**:只有检索范围、纳排和综合方法明确时才作为研究发现;普通资料汇总不得升级为系统结论。
|
||||
|
||||
## 证据与方法
|
||||
|
||||
- 明确对象、用途、非目标和适用情境;按需披露委托 / 执行方、总体与纳排、抽样 / 招募、样本量、工具 / 题项、采集方式 / 语言 / 时点、响应 / 脱落、加权、编码 / 分析和质量控制。
|
||||
- 写明偏差、缺失、反例、负结果、替代解释及其可能方向;透明报告不等于设计无偏,也不证明结论可复现。
|
||||
- 人员或敏感研究在适用规则下确认知情同意、撤回与伤害风险、匿名 / 保密、访问和数据用途;未获授权不公开可识别材料、原始数据或代码。
|
||||
- 引文只说明有出处的体验或机制,不把单个引文写成频率;结论只推广到设计和样本支持的人群、时间与环境。
|
||||
- 无原始材料只能产出研究范围或计划,不能生成 findings;方法或样本缺失时用 `[抽样方法待核]`、`[采集时点待核]` 并收窄为探索性观察。关键伦理、授权或方法缺口会改变结论时标记 `blocked`。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
独立答案、证据强度与关键限制 → 问题 / 范围 / 既有知识 → 方法 / 样本 → 按问题或主题组织的发现 → 解释、反例与替代解释 → 有边界的建议 / 验证 → 局限、来源与必要附录。摘要覆盖目的、方法、发现、含义和限制;正文以“主张 → 证据 → 限定”推进,不按作业时间线罗列过程,不用组件数量代替研究质量。
|
||||
@@ -1,25 +0,0 @@
|
||||
# Genre Contract: Retrospective / 复盘 (`workplace.retrospective`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;坦诚、无责备、因果克制,围绕证据和下一轮改变,不用“加强沟通 / 持续关注”代替可验证实验 |
|
||||
| 内容逻辑 | 界定已结束周期 / 事件,再按“目标 / 证据 → 预期与实际 → 聚类观察 → 洞见 / 待验证因果 → 保留项 → 少量改进实验 → 复查”推进 |
|
||||
| 事实 / 边界 | 事实 / 观察、解释 / 假设、洞见和行动分层;结论回链事件、指标或交付物;根因仅在证据充分时声明,否则写可证伪假设;保护必要隐私 |
|
||||
| 错误 | 周报换标题、成绩陈列 / 情绪宣泄、个人归罪、单一根因臆测、行动无 owner / 验证 / 跟踪、不回看上轮或把模板便签当结论,任一出现即失败 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用于回看明确迭代、阶段、项目或事件,形成可复用学习并改变下一轮做法。当前状态与升级需求走 `weekly-report.md`;仍在未知中止损、取证、恢复或调查生产事故走 `technical-doc.md`。生产事故可由 Technical 主文承载影响 / 时间线 / 根因 / 恢复,再附本体裁的团队学习层。
|
||||
|
||||
## 证据与因果
|
||||
|
||||
- 开头界定范围、时间、目标 / 原计划、参与视角和已知证据;不得补造指标、时间线、共识、原因或行动。
|
||||
- 同时识别应保留与应改变的条件,按影响聚类;以系统、流程、工具、接口和当时条件为对象,不把惩罚叙事冒充根因。
|
||||
- 个人工作心得 / 成长反思以一个真实事件或转折为证据,呈现“当时判断 → 反证 / 后果 → 新认识 → 下一次可观察行为”;不代写材料没有提供的情绪、动机、心路或成长。
|
||||
- 证据不足时写“促成条件 / 假设 + 验证方式”,不能用确定语气。缺基线用`[基线待补]`,涉及安全 / 法务而证据不足时转 Technical 并 `blocked`。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
便签、4Ls、Start / Stop / Continue 只是收集手段,成稿须综合为主题和判断。改进实验写动作、owner、目标时间、验证条件和跟踪位置,优先改变系统而不是要求人“更小心”;按需补上轮行动效果和下轮复查点。项目收尾可增加成本、范围、相关方和知识移交,但不机械扩章。
|
||||
@@ -1,37 +0,0 @@
|
||||
# Genre Contract: Consumer / 消费决策内容 (`router.consumer`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `rich`;具体、可信、可亲近,体验感服务于选择,不用热情语气替代测试、价格和适用条件 |
|
||||
| 内容逻辑 | 围绕具体消费场景,用“需求 / 使用条件 → 评价标准 → 体验或测试证据 → 权衡 → 适合谁 / 不适合谁”推进;合集和比较须共享标准,不按品牌逐段堆卖点 |
|
||||
| 事实 / 边界 | 只声称真实体验或有方法支撑的测试;披露赠品、佣金、赞助和其他重要关系;标明版本、时间、价格口径及限制;用户 / 专家引语、图片和前后对比须有授权、来源、语境与真实性依据,非文字证据须有文字等价信息;遵守目标法域和平台当期消费者、广告与高风险品类规则,关键利益关系、核心功效、安全条件或报价条款缺失时 blocked |
|
||||
| 错误 | 编造使用经历、未披露商业关系、无方法的评分 / 排名、把主观偏好写成客观最佳、隐藏不适用人群或总成本、用极端个案概括功效、过期信息仍当现状,任一出现即失败 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用于帮助读者购买、比较、避坑或判断某种生活方式是否适合自己。仅出现小红书、微信等平台名不触发;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,消费选择任务作为该 leaf contract 的硬约束,不再并读 Consumer。以公共事件核实为主走 Media,以价值判断为主走 Opinion,以品牌拥有的转化内容走 Marketing。
|
||||
|
||||
“测评”必须继续区分独立比较、真实个人体验和品牌演示:前两者可走本合同,品牌控制结论或行动入口时走 Marketing,并保留显著披露。
|
||||
|
||||
## 子类型
|
||||
|
||||
| 子类型 | 读者任务与推进 |
|
||||
|-|-|
|
||||
| 单品体验 / 好物分享 | 判断某物在真实场景是否值得;使用背景 → 观察 → 优缺点 → 适用人群 |
|
||||
| 对比测评 / 排名 | 在同一任务下选择;方法与样本 → 共同标准 → 结果 → 权衡与不确定性 |
|
||||
| 合集 / 清单 | 快速缩小候选范围;选择门槛 → 分组理由 → 各项差异 → 最终选择路径 |
|
||||
| 探店 / 服务体验 | 判断是否到访或购买服务;时间地点 → 实际流程 / 价格 → 体验证据 → 限制 |
|
||||
| 生活方式内容 | 判断实践成本与可复制性;目标 → 做法 → 真实投入 / 结果 → 适用边界 |
|
||||
|
||||
## 证据、披露与合规
|
||||
|
||||
- 第一人称体验交代使用时长、频率、版本和条件;未亲测就明确资料来源,不伪装成亲历。比较结论说明样本、标准、测量方法及未覆盖变量。
|
||||
- 把“真实 / 有效”“适合当前读者”“值得当前价格”分开判断。强参数品只保留会改变选择的指标,并解释版本口径和决策影响;使用评分 / 排名时公开标准、权重、主观边界和反转条件,安全或资格等一票否决项不得被平均分稀释。
|
||||
- 商业关系和激励在读者接触推荐时清楚出现,不能藏在模糊标签或文末。披露、重大限制和安全警示须就近可见,不能只藏在链接或视觉装饰中。
|
||||
- 健康、安全、金融、未成年人等高风险内容只写证据支持且适用法域允许的范围;不能核实的功效或个体化建议删除。规则冲突时按交付地区、渠道和发布时间核验,不把单一国家指南写成全球义务。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
先告诉读者评判基准,再给结论,才能让“推荐”可复核。优点与代价写在同一决策语境内,价格同时说明时间、地区、规格和附加成本。结尾给条件化选择,而不是人人适用的口号;关键参数待补时用具体占位并暂停对应结论,不能靠语气填空。
|
||||
@@ -1,36 +0,0 @@
|
||||
# Genre Contract: Creative / 叙事创作 (`router.creative`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `rich`;语言、节奏和视角服务指定叙事体验,表达自由不替代人物动机、因果和场景可读性 |
|
||||
| 内容逻辑 | 本合同只覆盖叙事创作;先确认体验、篇幅、视角和约束,用“人物欲望 → 阻力 → 选择 → 代价 → 变化”形成场景因果;剧本和互动叙事分别服从可演行动与有后果分支 |
|
||||
| 事实 / 边界 | 用户授权的虚构可创造,但真实背景、引用、既有作品正史和人物身份不得伪造;图片、题记、原作片段和创作参考须遵守来源与使用权限,非文字参考及互动分支图须附文字等价的关系、路径和状态说明;区分明确设定、合理创作补白和待确认约束;核心世界观 / 权利边界冲突且无法安全收窄时 blocked |
|
||||
| 错误 | 只堆设定不发生选择、人物为推进情节突然失去动机、冲突靠偶然或外力无代价解决、视角 / 时态无意漂移、剧本写成解释性小说、互动分支无状态差异、把诗歌静默纳入交付,任一出现即失败 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
仅用于网文、短篇故事、同人叙事、互动小说、剧本和故事大纲等以事件、人物选择和变化为核心的创作。诗歌、歌词、纯抒情散文不在本合同范围;收到这类请求时先确认目标或使用相应专用规则,不因“Creative”一级名称而静默扩写。
|
||||
|
||||
以论点和证据表达判断走 Opinion;以真实个人经历建立专业信誉走 Personal Brand。世界观说明若目标只是知识解释,不因带角色名就成为故事。
|
||||
|
||||
## 子类型
|
||||
|
||||
| 子类型 | 读者任务与推进 |
|
||||
|-|-|
|
||||
| 短篇 / 网文 | 获得连续叙事体验;触发变化 → 升级阻力 → 关键选择 → 后果 / 回响 |
|
||||
| 同人叙事 | 在约定正史与角色核心上体验新情境;明确时间点 / 偏离点 → 角色选择 → 新后果 |
|
||||
| 剧本 / 短剧 / 叙事短视频 | 看见可拍、可演的行动与冲突;媒介 / 时长 / 制作约束 → 场景目标 → 视觉、行动、声音 / 对话 → 转折 → 场景状态变化 |
|
||||
| 互动小说 | 作出有信息依据且有后果的选择;状态 → 选择 → 反馈 → 状态改变 → 后续分支 |
|
||||
| 故事大纲 | 判断故事能否成立并继续创作;前提 → 人物弧 → 节点因果 → 高潮选择 → 结局变化 |
|
||||
|
||||
## 设定与真实性
|
||||
|
||||
- 先锁定用户给定的人物、关系、禁区、正史时间点和期望体验;未指定的创作空间可以补白,但不得覆盖明确约束。关键歧义有多个会显著改变成品的方向时先询问。
|
||||
- 使用真实地点、历史、科学或文化材料时核验会影响情节的事实;有意架空应让读者能辨认其虚构约定。同人创作不把自设冒充正史,也不虚构原作引语。
|
||||
- 真实人物、未公开经历、受保护素材和委托作品按授权边界处理;不能确认可用性时改为原创替代或保持 blocked。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
每个场景都让人物为目标采取行动,并在离场时改变信息、关系、资源或风险。细节同时承担感官、人物或伏笔功能,背景通过当前冲突释放,不集中讲解。对话要改变局面而非重复旁白;剧本动作、声音和调度须在声明的演员、场地、道具与媒介条件下可实现,不用固定“前三秒 / 每分钟一反转”公式代替因果。结局兑现前文建立的选择与代价。大纲可显式呈现结构,成稿则把结构转化为可体验的场景。
|
||||
@@ -1,39 +0,0 @@
|
||||
# Genre Contract: Knowledge / 知识与教程 (`router.knowledge`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;具体、可操作、按读者水平解释;科普可生动、有好奇心,但不得牺牲准确性或编造戏剧性 |
|
||||
| 内容逻辑 | 先选择唯一主模式和读者起点,再承诺一个理解、学习、一次操作、检索或选择结果;第一屏给适用对象、目标和关键前置,概念、步骤、练习 / 验证、反馈与例外按需渐进展开 |
|
||||
| 事实 / 边界 | 事实、版本、命令、UI 路径和链接须核验;示例与规则分开;截图 / 案例不得泄露敏感信息;已知自助路径可写,组织受控重复作业走 SOP,设计、精确技术契约或未知诊断走 Technical |
|
||||
| 错误 | 不声明读者起点;教程变理论课;学习计划无基线 / 完成标准 / 调整规则;只有原则无步骤 / 例子;步骤无结果或验证;版本、权限、环境缺失;编造命令、UI 或链接;FAQ 脱离真实问题;资源合集无标准 / 注释 / 维护;视觉成为唯一信息;把未知排障写成确定答案 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
适用自主理解、学习 / 备考规划、一次已知任务或检索复用。`科普`、`教程`、`指南`、`攻略`、`学习计划`、`FAQ`、`知识库`、`资源合集`只用于召回;“知识库”是容器或渠道,不决定文章体裁。组织要求多人按批准版本重复执行并留痕走 [`sop-tutorial.md`](sop-tutorial.md);未来设计、API 精确契约、生产状态变更或未知根因走 [`technical-doc.md`](technical-doc.md);研究或数据形成新洞察走 Report。
|
||||
|
||||
## 主模式
|
||||
|
||||
| 模式 / 读者任务 | 结构推进 |
|
||||
|-|-|
|
||||
| Explanation / 科普:建立正确心智模型 | 现象 / 误区 → 概念模型 → 机制与证据 → 例子 → 争议、限制与适用边界 |
|
||||
| Tutorial:通过受引导练习获得技能 | 学习目标 → 起点 / 环境 → 安全练习 → checkpoint → 复盘与下一步 |
|
||||
| Learning plan:在现实约束下持续提高 | 基线诊断 → 可观察的阶段目标 → 练习 / 资料 / 时间 → 完成标准与反馈 → 调整规则 |
|
||||
| How-to:完成一个已知目标 | 目标 → 前置 → 最短有效步骤与可观察结果 → 变体 / 已知错误 → 完成验证 |
|
||||
| FAQ / known troubleshooting:快速找到已验证答案 | 按真实问题或症状分组 → 直接答案 → 必要条件 / 操作 → 相关内容;需要新假设或根因调查时转 Technical |
|
||||
| Resource guide:按标准选择资源 | 使用场景 / 筛选标准 → 分类 → 每项适配、代价与访问条件 → 维护信息 |
|
||||
| Reference / KB article:检索并复用事实或解法 | 上下文 / 适用版本 → 事实或 issue-resolution → 限制 / 相关项 → 时效性强时标 owner / last verified |
|
||||
|
||||
## 事实、步骤与维护
|
||||
|
||||
- 明确受众的已有知识、范围 / 非范围、版本、环境、权限与风险;术语在首次需要时解释,不先灌输完整理论。
|
||||
- 学习计划按阶段 / 能力、可用时间、既有任务和可得资料控制强度;目标拆成可观察表现,每阶段合写练习、完成标准、反馈和调整条件。会显著改变安排的缺口先问或条件化,不补造基础 / 时间。
|
||||
- 顺序任务一项写一个清楚动作,紧邻给可观察结果;命令、输入、输出和成功验证须能在声明环境中复现,危险或不可逆警告必须在动作前。
|
||||
- FAQ 只收真实用户问题或检索需求;否则按用户任务重组。资源指南先写选择标准,再给有描述的精选链接,不用外链代替核心上下文。
|
||||
- 时效性内容标适用版本 / 时间并说明维护边界;复杂视觉须有可传达同等信息的正文,图片、案例和代码不得成为无解释的唯一依据。
|
||||
- 版本或权限不明时用 `[适用版本待核]`、`[所需权限待确认]` 并只写不受影响部分;未验证命令或链接不进入发布稿。缺口可能造成损失、安全风险或关键分叉时标记 `blocked`。
|
||||
|
||||
## 高质量写法
|
||||
|
||||
第一屏让读者知道能理解、学会、完成或找到什么;用读者语言、具体动词和可验证结果推进,每节只增加必要的新理解或动作。先给最短可行路径,再在需要处补原理、变体和进一步阅读;示例只服务迁移,不扩张为用户未要求的全套内容,也不用丰富组件掩盖解释不足。
|
||||
@@ -1,40 +0,0 @@
|
||||
# Genre Contract: Marketing / 营销与公关 (`router.marketing`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `rich`;清楚、有吸引力且可行动,表达强度不得超过承诺、证据和授权,紧迫感不得制造误导 |
|
||||
| 内容逻辑 | 先锁定受众、漏斗阶段和唯一主要读者结果;转化内容用“场景 / 问题 → 有边界的价值主张 → 证据 → 关键条件 / 异议 → 一个 CTA”推进,公关稿按已授权事实、相关方影响、组织回应和后续更新推进 |
|
||||
| 事实 / 边界 | 所有客观、比较、功效和稀缺性主张发布前有相称证据;价格、资格、期限和限制就近可见;广告身份与商业关系按目标法域 / 平台规则披露;评价、案例、引语、图片和活动素材须真实、可核且获授权,非文字证据须有文字等价信息;核心主张证据、适用法域、发布授权、关键交易条件或任务要求的行动入口缺失时 blocked |
|
||||
| 错误 | 无证据的“最佳 / 保证 / 第一”、隐藏限制或自动续费、伪造倒计时 / 库存 / 评价、把广告伪装成独立报道、未经授权承诺赔付或责任、转化内容多个 CTA 争抢、用复杂 block 掩盖价值缺口,任一出现即失败 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用于组织拥有或授权、目标是认知、转化、留存或公共关系管理的内容。由新闻机构独立选题、核实和报道的内容走 Media;组织自有新闻稿、媒体通稿、品牌声明和回应口径走 Marketing,即使采用新闻结构也不变成独立报道。
|
||||
|
||||
个人真实体验用于帮助消费选择时走 Consumer;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,营销目标、商业关系和交易条件作为该 leaf contract 的硬约束,不再并读 Marketing。出现“新闻稿、软文、活动文案”只作召回信号,仍须确认发布主体、受众、行动和商业关系。
|
||||
|
||||
内部营销策划、增长方案或活动执行计划不因“营销”进入本合同:比较打法走 Business Analysis,请求预算 / 资源 / 战役批准走 Proposal,已定打法的协同落地走 Execution Plan;只有最终面向受众的传播、招募或转化成稿走 Marketing。
|
||||
|
||||
## 子类型
|
||||
|
||||
| 子类型 | 读者任务与推进 |
|
||||
|-|-|
|
||||
| 广告 / 短文案 | 迅速判断是否值得行动;受众场景 → 单一利益 → 可信理由 → 条件 → CTA |
|
||||
| 详情页 / 落地页 | 完成比较与转化;价值主张 → 关键能力 → 证据 → 方案 / 条款 → 异议 → CTA |
|
||||
| 活动 / 私域话术 | 判断是否参与并知道下一步;对象 → 收益 → 时间地点 / 门槛 → 风险限制 → 行动 |
|
||||
| 新闻稿 / 媒体通稿 | 获取组织已授权消息;可发布事实 → 为什么重要 → 引语 / 背景 → 联系与更新安排 |
|
||||
| 声明 / 危机回应 | 理解已知事实和组织行动;事件范围 → 已确认影响 → 当前措施 → 未知项 → 下次更新时间 |
|
||||
|
||||
## 证据、授权与合规
|
||||
|
||||
- 建立“主张—证据”对应:定量效果说明口径、样本和时间,比较主张保证对象与标准可比;图片、引语、评价和案例保留来源、必要语境及授权记录。
|
||||
- 披露和限制应让普通受众在作决定前看见并理解,不能由链接、模糊缩写或弱提示代替。规则随法域、媒介、品类和时间变化,交付前核验当期法律、监管与平台要求。
|
||||
- 公关内容只写已获授权的事实和承诺;事故原因、责任、补偿、调查结论未核定时明确 unknown。关键批准或法律审阅未完成,不生成可直接外发版本。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
价值主张具体到受众、场景和结果,证据紧跟对应主张。文案须锚定品牌独有资产、产品细节或品类语境;换成竞品名仍成立就返工。多版本应改变受众状态、主张、证据或场景并说明选择条件,不做同义改写。
|
||||
|
||||
删除不改变理解或行动的品牌空话,不把真实痛点升级为羞耻、身份不足或恐惧操控。有转化目标时,次级入口均服务同一主要行动;优惠资格和截止时间采用可比较字段,待补价格、库存或链接用语义化占位,并让受影响结论保持 blocked。
|
||||
@@ -1,36 +0,0 @@
|
||||
# Genre Contract: Media / 资讯媒体 (`router.media`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;准确、中立、紧凑,信息密度服从读者快速理解,不用戏剧化措辞替代事实强度 |
|
||||
| 内容逻辑 | 先确定快讯 / 报道、解释、人物特写或访谈的读者任务;关键信息优先,随后给证据、必要背景、相关方视角和仍未知事项,段落按重要性、因果或时间关系推进 |
|
||||
| 事实 / 边界 | 区分已核事实、来源说法、推断和 unknown;准确优先于抢发,关键主张可追溯,负面涉及方获得合理回应机会;引语须忠实可核,图片和原始材料须有使用权限、来源、语境说明及文字等价信息,更正、披露和关键缺口须直接可见;核心事实、来源真实性、发布权限缺失,或严重负面指控尚未提供回应机会时保持草稿并 blocked |
|
||||
| 错误 | 把组织自有通稿伪装成独立报道、标题超出证据、单一匿名来源承载重大指控、引语失真、事实与评论混写、遗漏重大反方或不确定性、图片无权利 / 来源 / 文字等价信息,任一出现即失败 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
本合同用于以独立采集、核实和公共理解为职责的新闻内容。请求出现“新闻稿、媒体稿、报道”只作召回信号:编辑方能独立核实、选择角度并承担报道判断时走 Media;由组织拥有、批准并面向媒体或公众发布的新闻稿、品牌声明和公关口径走 Marketing。
|
||||
|
||||
以立场说服为主走 Opinion;以购买决策和亲身体验为主走 Consumer;内部事实简报不因写得像新闻而改变读者任务。渠道名、标题风格或“像媒体一样写”均不能单独触发;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,资讯核实边界作为该 leaf contract 的硬约束。
|
||||
|
||||
## 子类型
|
||||
|
||||
| 子类型 | 读者任务与推进 |
|
||||
|-|-|
|
||||
| 快讯 / 硬新闻 | 尽快知道发生了什么及其可信程度;核心事实 → 来源与范围 → 必要背景 → 下一确认点 |
|
||||
| 解释报道 | 理解为什么发生、如何运作及争议在哪里;问题 → 机制 / 时间线 → 多方证据 → 已知边界 |
|
||||
| 人物 / 特写 | 通过可核场景和经历理解人物或议题;场景 → 关键变化 → 证据与他者视角 → 公共意义 |
|
||||
| 访谈 / 问答 | 准确获取受访者观点及上下文;交代身份与场景,忠实编辑问答,不补造连接语或立场 |
|
||||
|
||||
## 证据与真实性
|
||||
|
||||
- 为可能引发争议的事实保留可追溯材料,记录来源身份、接近事实的方式、核实状态和使用限制;匿名只在有公共价值且无法安全具名时采用,并说明读者判断所需的来源范围。
|
||||
- 引语逐字可核;压缩、翻译和转述不得改变含义。无法确认的数字、时间、身份或因果就近标明 unknown,不用“据悉”“有消息称”遮蔽来源质量。
|
||||
- 开盒、网暴、羞辱、未成年人或其他可能放大伤害的事件只保留理解事实、责任和传播机制所需的最少信息;不为证明热点而复刻身份线索、攻击性内容或未核传言。
|
||||
- 更正要说明改了什么;新证据改变核心判断时更新标题和结论。发布前无法核实的核心主张不得靠占位符放行。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
标题和导语只承诺正文已证明的内容。每段承担一个信息动作,并在首次出现时交代人物、机构、时间和口径;背景只保留改变理解的部分。多方说法按证据权重而非形式上的各打一板排列,不把可验证事实写成“双方观点”,也不把尚无结论写成确定因果。
|
||||
@@ -1,38 +0,0 @@
|
||||
# Genre Contract: Opinion / 观点评论 (`router.opinion`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `rich`;立场鲜明但措辞精确、公平,论证密度高于情绪密度,锋利不等于侮辱或夸张 |
|
||||
| 内容逻辑 | 明确可争辩的中心判断及其重要性,用理由和证据推进;对有实质争议的主张呈现最强相关反论并回应,结论说明判断边界或行动含义 |
|
||||
| 事实 / 边界 | 区分事实、推断、价值判断、预测和个人经验;事实可追溯,证据强度匹配主张强度,不把相关性写成因果或把个案外推为普遍规律;引语、图片和作品片段须有可核来源、必要语境与使用权限,非文字证据须有文字等价信息,重要利益关系须显著披露;关键事实缺失时收窄主张,无法成立则 blocked |
|
||||
| 错误 | 只有态度没有论点、稻草人反驳、选择性证据、人格攻击、标题先定罪、把经验冒充统计、隐藏重大反例或利益关系、结论超出论证,任一出现即失败 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用于帮助读者评估一个判断、立场或解释框架。事件复述和独立核实走 Media;围绕购买选择的测评走 Consumer;组织为行动或转化发声走 Marketing。出现“评论、专栏、观点”只是召回词,正文必须有可辨认的判断和论证任务。
|
||||
|
||||
文化评论关注作品、现象的意义和判断;若主要提供剧情复述或故事体验,不走本合同。个人经历可以作为观察入口,但若目标是展示经历与能力,走 Personal Brand。
|
||||
|
||||
## 子类型
|
||||
|
||||
| 子类型 | 读者任务与推进 |
|
||||
|-|-|
|
||||
| 时评 / 公共议题评论 | 判断事件意味着什么;争点 → 判断 → 证据与机制 → 反论 → 后果 / 建议 |
|
||||
| 商业 / 行业评论 | 评估策略、趋势或制度;基线 → 驱动因素 → 证据 → 替代解释 → 适用条件 |
|
||||
| 文化评论 | 理解作品或现象的价值;分析对象 → 解释框架 → 细读证据 → 限度 → 判断 |
|
||||
| 专栏 / 随笔 | 从观察或经验形成可迁移洞见;具体场景 → 反思 → 关联 → 有边界的结论 |
|
||||
|
||||
## 证据与论证
|
||||
|
||||
- 开头尽早写出“我主张什么”和“为什么现在值得讨论”,避免用大段背景延迟论点。每个理由回答一个潜在质疑,并由事实、例子、机制或可靠来源支持。
|
||||
- 反论选择真正能动摇中心判断的版本,不挑最弱说法;回应可以承认条件、修改范围或解释为何仍不改变结论。观点平衡不是机械分配篇幅。
|
||||
- 公共争议先拆清事实真伪、规则 / 权利、价值取舍、责任归属和 unknown,再分别判断;行动建议须对应具体主体、权限 / 义务、可用杠杆与代价,不用“多方协同”抹平责任边界。
|
||||
- 预测写明前提和时间范围;价值判断说明采用的标准。涉及他人动机、违法或伤害的判断不得凭语气升级为事实。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
段落之间形成“主张 → 理由 → 证据 → 推论”的可追链条,过渡词只标真实关系。文化评论选择一个能统摄正文的主分析轴,把情节、语言、镜头、声音、表演或结构写成“形式选择 → 产生效果 → 支持何种解释 / 评价”的证据链;比较或综述可有多个对象,但不能退化成剧情复述或维度清单,也不把效果直接冒充创作者意图。
|
||||
|
||||
结尾不复述全文,而是给出经反论校准后的判断、仍然未知的部分,或读者下一步应重新考虑什么。随笔可弱化显式论证标记,但不能牺牲观察与结论之间的可理解联系。
|
||||
@@ -1,36 +0,0 @@
|
||||
# Genre Contract: Personal Brand / 个人品牌 (`router.personal_brand`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;可信、具体、有辨识度,声音服从目标读者和真实经历,不用自我评价替代成果证据 |
|
||||
| 内容逻辑 | 从目标读者和目标机会出发,用“身份 / 价值定位 → 相关经历 → 可验证贡献 → 做事方式 → 下一步意图”组织;每项经历说明情境、本人动作、结果及与目标的关系 |
|
||||
| 事实 / 边界 | 职位、时间、职责、学历、技能、作品和指标须真实可核,个人贡献与团队成果分开;尊重保密、个人信息、雇主和作品权利,作品、图片和推荐语须确认归属、使用权限与必要语境,非文字证据须有文字等价信息;关键身份、时间、归属或公开权限缺失时用具体占位,无法安全表述则 blocked |
|
||||
| 错误 | 夸大头衔 / 技能 / 指标、把团队成果全归个人、关键词堆砌、伪造推荐语或客户、泄露敏感信息、作品无归属 / 权限、同一经历前后矛盾、渠道语气改变事实,任一出现即失败 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用于让招聘方、合作方、客户或专业社群判断“这个人是谁、做过什么、能带来什么”。仅出现平台名称不触发;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,个人身份、经历和信誉目标作为该 leaf contract 的硬约束,不再并读 Personal Brand。
|
||||
|
||||
以项目经验得失来改进下一轮走 Retrospective;以购买体验帮助他人选择走 Consumer;以组织身份转化客户走 Marketing。出现“介绍、主页、复盘”不能单独触发,须确认目标是个人能力和信誉呈现。
|
||||
|
||||
## 子类型
|
||||
|
||||
| 子类型 | 读者任务与推进 |
|
||||
|-|-|
|
||||
| 简历 / CV | 快速判断岗位匹配;摘要 → 相关经历与成果 → 技能 / 教育 → 必要补充 |
|
||||
| 求职信 / 自我介绍 / 简介 | 理解动机与差异化价值;目标 → 相关证据 → 工作方式 → 明确下一步 |
|
||||
| 个人主页 | 建立清晰定位并找到入口;一句定位 → 代表证据 → 领域 / 服务 → 联系或作品 |
|
||||
| 作品集 / 案例集 | 判断能力如何形成结果;问题 → 约束与本人角色 → 过程决策 → 结果与反思 |
|
||||
| 个人成长回顾 | 理解身份与能力变化;起点 → 关键选择 → 证据 → 学到什么 → 下一方向 |
|
||||
|
||||
## 证据与真实性
|
||||
|
||||
- 成果优先写可核结果及其口径,不能量化时写可观察变化、交付物或他人采用情况,不编造数字。明确“负责、协作、支持、批准”等角色差异。
|
||||
- 时间线、组织名、客户名、作品链接和推荐语在公开前确认准确与授权;需匿名时保留问题、本人动作和结果的判断价值,不留下可反推的敏感细节。
|
||||
- 技能由近期作品、职责范围或实际使用场景支撑;自我定位可以有主张,但不能使用未获认可的资质、奖项或身份。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
先筛选与目标读者最相关的经历,不把完整人生经历当作专业证明。经历条目以动作和影响开头,背景只写理解贡献所需的约束;案例说明权衡和本人判断,比工具清单更能证明能力。CTA 具体到希望发生的下一步,并只提供获授权的联系方式。
|
||||
@@ -1,9 +0,0 @@
|
||||
# Genre Router: Platform / 平台发布稿 (`route_platform`)
|
||||
|
||||
仅当最终交付物是小红书笔记、微信公众号文章或邮件成稿时进入本 router;按目标平台选择且只读取一个 leaf。仅把平台作为研究对象、信息来源或业务渠道时不触发;多平台成稿分别路由和生成。
|
||||
|
||||
| 关键词 | Leaf |
|
||||
|-----------------|------------------------------------|
|
||||
| XHS、小红书 | [`xiaohongshu.md`](xiaohongshu.md) |
|
||||
| 微信、wechat | [`wechat.md`](wechat.md) |
|
||||
| 邮件、email、e-mail | [`email.md`](email.md) |
|
||||
@@ -1,10 +0,0 @@
|
||||
# Genre Router: Report (`router.report`)
|
||||
|
||||
用数据、样本或研究形成洞察走本类;按读者任务选择且只读一个 leaf,关键词仅用于召回,`报告 / 分析 / 研究 / 数据 / 白皮书`单独不决定路由。组织执行 / 批准走 Workplace,自主学习走 Knowledge。
|
||||
|
||||
| 读者任务 / 关键词、强信号与排除 | Leaf |
|
||||
|-|-|
|
||||
| 回答明确研究问题,方法、样本和可推广边界决定可信度;调研报告、访谈 / 问卷 / 用户研究。仅解读既定指标时排除 | [`research-report.md`](research-report.md) |
|
||||
| 解读已定义指标、趋势、分布、漏斗或实验观察值;数据报告、经营数据、指标复盘。需重新设计样本回答问题时排除 | [`data-report.md`](data-report.md) |
|
||||
| 让专业读者系统理解并评估问题、框架或方案;白皮书、行业框架、技术 / 政策议题。获客、产品卖点或 CTA 为主时排除 | [`white-paper.md`](white-paper.md) |
|
||||
| 比较战略、投资、市场、产品或资源选项及成本、收益、风险;商业分析、可行性、进入 / 自建或采购判断。正文要求具名决策者选择 / 批准,或形成授权、资源拨付、执行承诺入口时排除 | [`business-analysis.md`](business-analysis.md) |
|
||||
@@ -1,17 +0,0 @@
|
||||
# Genre Router: Workplace (`router.workplace`)
|
||||
|
||||
组织内决策、执行、留档走本类;先按读者任务与生命周期选择且只读一个 leaf,关键词仅用于召回,排除信号优先于同名词。
|
||||
|
||||
| 读者任务 / 关键词、强信号与排除 | Leaf |
|
||||
|-|-|
|
||||
| 快速知悉、短判断或会前准备;备忘录、决策摘要、会前材料。完整批准论证走 Proposal,周期状态走 Weekly | [`memo-brief.md`](memo-brief.md) |
|
||||
| 按周期判断相对承诺的状态、偏差、风险和下一步;周报、日报、月报、项目状态。原因学习走 Retrospective,完整分析走 Report | [`weekly-report.md`](weekly-report.md) |
|
||||
| 请具名决策者批准方向、预算、资源或执行承诺;提案、立项、资源申请。已定产品行为走 PRD | [`proposal.md`](proposal.md) |
|
||||
| 将方向已定的一次性项目、变更、专项行动或营销战役转成可协同推进的交付、依赖、里程碑与验收;项目计划、执行方案、实施计划。仍在比较方向或请求批准走 Proposal / Report,重复稳定路径走 SOP | [`execution-plan.md`](execution-plan.md) |
|
||||
| 将已授权的内部规则 / 安排、可复核的检查整改记录或已核定组织立场写成正式载体;制度、公司通知、整改记录、讲话底稿。待批准方向走 Proposal,复杂执行走 Execution Plan,法定公文走 Official;`正式`单独不触发 | [`formal-doc.md`](formal-doc.md) |
|
||||
| 党政机关法定公文拟制、审校或制发;明确要求公文 / 红头 / 套红 / 正式发文,或法定文种与机关行文关系、文号、主送等制发要素共同出现。`通知 / 报告 / 公告 / 纪要 / 正式 / 官方`单独不触发 | [`official-redhead.md`](official-redhead.md) |
|
||||
| 记录已发生会议的决定、异议、行动和批准状态;会议记录、行动项。逐字稿不走本 leaf,法定公文纪要走 Official | [`meeting-minutes.md`](meeting-minutes.md) |
|
||||
| 从已结束周期 / 事件提炼证据化学习并改变下一轮;复盘、回顾、经验教训。当前状态走 Weekly,活跃未知事故走 Technical | [`retrospective.md`](retrospective.md) |
|
||||
| 方向已定,定义用户问题、范围、产品行为与验收;PRD、用户故事、验收标准。是否投入走 Proposal,实现取舍走 Technical | [`prd.md`](prd.md) |
|
||||
| 评审未来技术设计、查询精确契约或调查未知故障;RFC、API、架构、事故调查。产品行为走 PRD,已定重复路径走 SOP | [`technical-doc.md`](technical-doc.md) |
|
||||
| 按已批准、可验证路径重复达到终态,或为已知事件类别预置响应路径;SOP、runbook、值班 / 操作手册、BCP / 处置预案。应急预案若主要发布权威职责走 Formal,法定制发走 Official,活跃未知事故走 Technical,一次学习教程走 Knowledge | [`sop-tutorial.md`](sop-tutorial.md) |
|
||||
@@ -1,41 +0,0 @@
|
||||
# Genre Contract: SOP / Runbook (`workplace.sop_tutorial`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;命令式、具体、顺序稳定,一步一动作并紧邻可观察判据,不写无条件的“适当 / 必要时” |
|
||||
| 内容逻辑 | 先定 routine / controlled / high-risk,并识别是否为响应预案,再按“版本 → 触发 / 范围 / 终态 → 角色 / 前置 → 动作 / 判据 / 证据 → 异常 / 停止 / 恢复 → 完成记录 / 复审”推进 |
|
||||
| 事实 / 边界 | owner、版本、环境、资格、权限、工具、命令、阈值、预期结果和恢复路径均须已验证;警告在动作前;命令成功不等于业务终态;流程图 / 示意不能替代可执行步骤、判据与异常路径,须有文字等价;关键未知使可发布稿 `blocked` |
|
||||
| 错误 | 教程冒充 SOP、未分风险、缺 owner / 版本 / 前置、一条多动作、编造入口 / 阈值 / 权限 / 命令、停止后状态未知、只写“必要时回滚”或未验证终态;响应预案无分级触发、替补指挥、降级路径或解除条件,任一出现即失败 |
|
||||
|
||||
## 适用与风险分类
|
||||
|
||||
用于组织规定的重复作业、沿已批准路线取得确定终态的 runbook,或针对已知事件类别预置并授权的响应 / 业务连续性路径。一次性自助 how-to / 学习走 Knowledge;未来设计取舍、活跃未知故障或临场根因调查走 `technical-doc.md`;只建立组织权威、职责或发布要求而不提供现场步骤走 `formal-doc.md`。“教程 / 操作 / 手册 / 应急预案”单词本身不触发。
|
||||
|
||||
| 分类 | 增量证明义务 |
|
||||
|-|-|
|
||||
| `routine` | 阶段或终态验证、常见异常和升级 |
|
||||
| `controlled` | 再含审批、接受 / 拒绝、偏差记录、变更复审和代表性试跑 |
|
||||
| `high-risk` | 再含 precheck、hold point、go / no-go、停止条件,以及可执行 rollback / fallback / roll-forward 和恢复验证 |
|
||||
|
||||
## 响应预案增量
|
||||
|
||||
- 涉及人身安全或法定直报时,其优先级高于业务与财产;按已核风险设置进入、升级、降级和解除条件,明确指挥 / 决策权限、替补角色、首轮动作、信息报送与对外口径边界。联络序列、等待时长和重试次数须预先批准;未知时保留占位,仅放行无需等待授权的安全动作。
|
||||
- 预设负责人失联、断网断电、主资源不可用等降级场景及可达的安全终态;恢复须验证真实业务终态。发布前按风险做桌面推演或代表性演练,高风险场景包含故障注入并记录缺口、owner 和复验。
|
||||
|
||||
## 文控与证据
|
||||
|
||||
写明触发、目标终态、范围、owner / 资格、当前版本 / 环境、前置、权限、工具和输入。命令、参数、阈值、预期输出、备份 / 恢复资产和试跑结果须来自真实环境;流程变更后更新、复审并标 superseded 状态。
|
||||
|
||||
关键缺口就近使用`[待环境 owner 验证]`等具体占位。命令、权限、阈值、停止或恢复判据未知时只保留安全只读 precheck,不得发布可执行稿,`Publish Gate = blocked`。
|
||||
|
||||
## 步骤、异常与恢复
|
||||
|
||||
- 每个关键步骤只写一个动作,紧邻可观察结果、阈值与证据;验证需要操作时另列一步。未知偏差停止于已知安全状态,记录证据并升级。
|
||||
- high-risk 在不可逆动作前设置 hold point:列 go / no-go 信号、决策人和信号缺失时的安全终态。rollback 写触发条件、适用范围、步骤、阈值、停止点和恢复后业务验证,不能只写命令回执。
|
||||
- 有状态迁移另列不可逆点、写入归属、checkpoint / 幂等,以及完整、无重复、有序或等价验证;关闭 fallback 前必须证明新终态稳定。
|
||||
|
||||
## 高质量写法
|
||||
|
||||
让具备规定基础资格但不熟流程的人可独立复现;选择条件写在动作前,稳定原理链接出去,不混入原理课或临场诊断。按风险裁剪篇幅但不删证明义务;按适用治理要求由代表性执行者试跑,未经任何实际验证不得发布。
|
||||
@@ -1,38 +0,0 @@
|
||||
# Genre Contract: Technical Document / 技术文档 (`workplace.technical_doc`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `rich`;精确、可证伪、术语与版本稳定;在有明确内容作用时用代码、表格、架构 / 状态 / 时序图和其他 rich block 降低实现与诊断成本,但不让视觉组件替代契约、证据或操作说明,规范词仅在明确采用的互操作 / 安全 / 验收语义中使用 |
|
||||
| 内容逻辑 | 必须且只能选 design_rfc、api_reference、incident_diagnostic 一种主模式;分别按“证据 → 取舍 / 设计 → 验收”“契约 → 错误 / 兼容”“影响 → 假设 / 检查 → 验证 / 升级”推进 |
|
||||
| 事实 / 边界 | 标对象、环境、版本、时间、范围和证据窗;事实、推断、决定、未知分开;示例 / 图不替代契约;任何改状态动作须有授权、影响、停止、还原和恢复验证,关键缺口按 reader impact 处理 |
|
||||
| 错误 | 按关键词路由、三模式混写、设计无取舍 / 验收、reference 漏权限 / 错误 / 生命周期 / 兼容、未知故障直接定根因、改状态无授权 / 停止 / 还原或图作唯一证据,任一出现即失败 |
|
||||
|
||||
## 先选唯一主模式
|
||||
|
||||
| 主模式 | 读者任务 | 排除 |
|
||||
|-|-|-|
|
||||
| `design_rfc` | 评审者能批准并实现未来技术状态,理解替代、后果和验收 | 产品可观察行为走 PRD;既定路径走 SOP |
|
||||
| `api_reference` | 调用者无需猜版本、权限、输入、行为、副作用、错误与生命周期 | 仍在讨论接口取舍时走 design_rfc |
|
||||
| `incident_diagnostic` | 响应者以安全、有区分度的动作缩小未知、止损、恢复或升级 | 单纯团队学习走 Retrospective;已知重复处置走 SOP |
|
||||
|
||||
## 共同证据边界
|
||||
|
||||
标明对象、环境、版本、时间、范围 / 前置和证据位置 / 窗口;结论回链仓库、IDL / schema、日志、metrics、traces、变更记录或验证实验。缺口就近使用具体占位、收窄或 `blocked`;数据分级、访问、保留、重放、owner、时限和升级只在适用时形成门禁。
|
||||
|
||||
代码与命令示例须实际验证并标环境 / 版本;架构、状态或时序图必须附文字等价,不能成为唯一证据或唯一操作说明。
|
||||
|
||||
## Design RFC
|
||||
|
||||
按问题证据 → 目标 / 非目标 → 约束 / 不变量 → 真实备选与同口径取舍 → 接口 / 数据 / 状态设计 → 失败、安全、兼容与迁移 → 上线 / rollback → 可观测性、测试 / 验收 → 未决决定推进。每项关键决定写 why、被否方案及后果;不得隐藏低置信度或版本偏差。
|
||||
|
||||
## API Reference
|
||||
|
||||
写清版本 / 环境 / 权限 / 签名、输入约束、行为 / 副作用 / 幂等、输出、已知错误及可操作恢复、限流 / 分页 / 重试、兼容 / 弃用。事件、异步、CLI、SDK、流式按需补 channel / message、交付 / 顺序、生命周期 / 耗尽、I/O、取消与背压;未知语义明确 unspecified,不从示例推断承诺。
|
||||
|
||||
## Incident Diagnostic
|
||||
|
||||
按影响与 expected / actual → 当前状态与证据链 → 可证伪假设 → 信息增益高且副作用低的检查 → 止损 / 恢复验证 → 升级与后续 RCA 推进。每项检查写预期观察及其支持 / 排除的假设。
|
||||
|
||||
修改状态前必须确认授权、目标范围、潜在副作用、停止条件、还原路径和恢复判据;分开止损、根因与永久修复。缺证据、授权、owner、还原或升级路径时只给安全只读检查并 `blocked`;涉及安全 / 法务时先保全证据和升级。
|
||||
@@ -1,38 +0,0 @@
|
||||
# Genre Contract: WeChat Official Account / 微信公众号文章 (`platform.wechat`)
|
||||
|
||||
## 核心定位(硬约束)
|
||||
|
||||
- 交付物是飞书文档中的“微信公众号风格”内容稿,不代表实际发布,也不执行微信平台审核、流量、商业或发布规则。
|
||||
- `presentation_mode` 使用 `rich`:可信、有观点、有叙事或论证推进,在专业感与亲近感之间保持平衡。公众号不是加长版小红书,也不是公文或报告换皮。
|
||||
- 一篇只服务一个读者任务和一个可兑现承诺;标题、封面、摘要、导语、正文与结尾围绕同一主线。不编造亲历、身份、数据、引语、案例或效果;无来源时不用“多数、普遍、研究表明”等统计口吻,材料不足时明确收窄表达。
|
||||
- 飞书源稿禁止使用 `callout`;生成后通过 Draft Parse Gate 的 `profile.blocks` 检查,其他 block 按真实信息关系选择。
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用户明确要“微信公众号文章、公众号推文、微信长文、微信爆文、公众号风格”时使用,内容保存在哪里不影响本合同生效。
|
||||
|
||||
普通微信聊天消息、群公告、朋友圈文案、视频号口播、小程序页面和服务通知不走本合同。仅把微信作为研究对象、信息来源或业务渠道时也不触发;若同时要公众号稿和正式体裁,分别生成,不混写。
|
||||
|
||||
## 内容模式
|
||||
|
||||
| 模式 | 内容脊柱 |
|
||||
|-|-|
|
||||
| 知识 / 方法 | 读者处境 → 核心原理 / 结论 → 方法与验证 → 成本、例外和适用边界 → 可执行认识 |
|
||||
| 观点 / 解释 | 现象或争点 → 中心判断 → 理由、证据与机制 → 相关反论 / 边界 → 校准后的结论 |
|
||||
| 资讯 / 热点 | 已确认事实 → 为什么重要 → 必要背景与多方信息 → 争议 / 未知 → 当前结论或更新点 |
|
||||
| 案例 / 故事 | 具体场景 → 选择与行动 → 可观察结果 → 代价 / 失误 → 可迁移洞见 |
|
||||
| 品牌 / 行动 | 读者场景 → 有边界的价值 → 证据 / 体验 → 条件与取舍 → 清楚结论 |
|
||||
|
||||
## 成稿要求
|
||||
|
||||
- 先钉住具体读者、核心问题与中心判断;内部比较信息清晰型、问题 / 冲突型、观点浓缩型标题,成稿只输出既有张力又不透支正文的一个。
|
||||
- 标题负责建立准确预期;摘要按需补充关键背景、判断或阅读收益,不复述标题。摘要、导语和首节必须各有信息增量。封面只保留一个视觉中心,图片文案不制造第二个主题。
|
||||
- 导语在首屏内用具体场景、问题、变化或判断说明“为什么值得读”,随后尽快进入主线,不用宏大背景、客套话或悬念拖延核心信息。
|
||||
- 正文沿一条逻辑线展开,小标题概括本节增量。段落各有一个主要意思,但长短随内容变化:重点句可独立成段,证据、故事和推理要保留完整上下文,避免短句过多造成逻辑断裂。
|
||||
- 使用自然、可交流的书面语;用具体细节、例子、转折和取舍形成作者声音,不靠网络热词、排比口号或统一句式制造“爆文感”,避免连续复用同一反转句式。
|
||||
- 完整稿至少给出一个封面或正文视觉方案;已有图片时就近用于提供证据、解释信息、建立场景或调节长文节奏。图片不设固定数量,也不为“图文并茂”强塞装饰图,正文仍须独立可读。
|
||||
- 结尾回扣开头问题或中心判断,留下结论、影响或自然的下一步;互动句、emoji 和话题标签均按需使用,不要求固定收尾动作。
|
||||
|
||||
## 交付前检查
|
||||
|
||||
确认标题没有透支正文,摘要与导语没有重复,文章主线连续,每节都在推进事实、故事、论证或方法,手机上容易扫读但不过度碎片化,图片确实帮助理解,且没有空洞口号、标题党、模板腔或虚构事实。
|
||||
@@ -1,24 +0,0 @@
|
||||
# Genre Contract: Weekly / Status Report (`workplace.weekly_report`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;具体、短、面向判断,稳定使用最小字段与状态语义,不用“持续推进”代替产出 |
|
||||
| 内容逻辑 | 围绕报告对象和周期,按“总体状态 / 最大变化 → 对照基线的产出 → 偏差 / 风险 / 依赖 → 下一里程碑 → ask”推进,只写影响判断的变化 |
|
||||
| 事实 / 边界 | 状态须回链范围、时间、质量、成本、资源或阻塞证据;事实、当前状态和下期计划分开;无基线或数据不足时写 unknown,不猜完成率、原因、owner 或日期 |
|
||||
| 错误 | 活动流水账、无周期 / 基线、健康色无判据、风险被埋、猜测根因冒充事实、下一步无里程碑、ask 不可执行或自动汇总不可追,任一出现即失败 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用于按固定或约定周期判断当前相对目标 / 计划 / 承诺的位置。解释已结束周期为何如此并改变下一轮走 `retrospective.md`;完整指标洞察走数据报告;一次性高层知会走 `memo-brief.md`。“报告 / 进展”单词本身不触发本体裁。
|
||||
|
||||
## 状态与证据
|
||||
|
||||
- 标明报告对象、周期 / 截至时间;进展使用已验收产出、里程碑或有口径指标,会议数、沟通和投入时长本身不等于进展。
|
||||
- On track / 红黄绿等状态须有预先定义或就近说明的判据。无基线时明确“无法判断是否按计划”,而不是默认绿色。
|
||||
- 风险、问题、依赖和阻塞按已知程度写影响、当前缓解、责任方与升级需求;冲突数据并列保留并标`[口径待核]`。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
个人短更新可收缩,项目群 / 月报可按需增加趋势、成本或预算,但不复制无用栏目。优先写相对上期和相对承诺的 delta;稳定低风险项可链接原记录。ask 写明对象、事项和需要时间,关键数据延迟时说明最近可用时间点及其判断影响。
|
||||
@@ -1,32 +0,0 @@
|
||||
# Genre Contract: White Paper / 白皮书 (`report.white_paper`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;系统、清楚、克制;权威来自真实主体、证据与归属,不来自篇幅、正式腔或视觉复杂度 |
|
||||
| 内容逻辑 | 先确认白皮书类型、发布主体、专业读者和期望判断,再用证据建立问题、评价标准或框架、论证、反例及应用边界;框架必须实际解释或比较 |
|
||||
| 事实 / 边界 | 客观主张连接真实来源、时点、范围和限制;事实、解释、价值判断、提议与品牌立场可区分;政策身份、发布状态、利益、资助和案例选择不得虚构或隐匿;证据图与材料须确认使用权、来源和说明,复杂视觉附文字等价信息 |
|
||||
| 错误 | 政策与品牌身份混写;标题或版式伪造权威;宏大背景填篇幅;自创框架仅作装饰;来源不可追;单一案例冒充共识;忽略反证 / 利益冲突;CTA 吞没证据;复杂组件代替论证 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
让专业读者系统理解并评估问题、框架或解决路径。先区分有权主体的政府政策白皮书与专业、技术或品牌资助白皮书;`白皮书`、`正式`、`权威`单独不产生政府或标准身份。明确研究问题和方法为核心走 [`research-report.md`](research-report.md),特定组织选项决策走 [`business-analysis.md`](business-analysis.md),设计 / RFC / 接口契约走 Technical,产品卖点、获客或 CTA 为主走 Marketing。
|
||||
|
||||
## 子类型
|
||||
|
||||
- **政府政策白皮书**:只有真实有权主体可使用;准确标政策、咨询、立法与发布状态,不模拟批准或法律效力。
|
||||
- **政策 / 专业问题白皮书**:围绕问题、证据、评价标准、方案和影响形成可审查论证。
|
||||
- **技术 / 行业 landscape 白皮书**:解释技术、标准或系统框架;一旦主要任务是批准实现设计或查询精确契约,改走 Technical。
|
||||
- **品牌资助白皮书**:证据评估仍须是主体任务;披露资助、产品利益和案例选择,转化内容与论证分层。
|
||||
|
||||
## 证据与边界
|
||||
|
||||
- 开头明确作者 / 发布主体、读者、使用场景、范围、文档状态、核心立场和期望判断。
|
||||
- 主张强度匹配证据层级;有限测试、相关观察、厂商数据或单一案例不得扩写成绝对承诺或行业共识。
|
||||
- 框架的每一层都须增加解释、比较或选择价值;问题原因、评价标准与方案逻辑相连,并处理重要反证、替代解释和可行性限制。
|
||||
- 主体或授权不明时用 `[发布主体待确认]`,不得写成政府、官方或标准;核心证据不足时收窄为 concept note / outline。利益关系或关键政策状态无法确认的发布稿标记 `blocked`。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
独立摘要(主体、论点、证据边界) → 问题与现有证据 → 评价标准或核心框架 → 逐层论证、方案与反例 → 应用 / 政策含义及条件 → 限制、利益关系与来源。摘要让忙碌读者复述主张和保留条件;长篇才增加目录或附录,不以背景、封面、缩写或组件制造权威感。
|
||||
@@ -1,37 +0,0 @@
|
||||
# Genre Contract: Xiaohongshu Note / 小红书笔记 (`platform.xiaohongshu`)
|
||||
|
||||
## 核心定位(硬约束)
|
||||
|
||||
- 交付物是飞书文档中的“小红书风格”内容稿,不代表实际发布,也不执行小红书平台审核、禁词、流量或商业规则。
|
||||
- `presentation_mode` 使用 `rich`:鲜活、有节奏、有画面感。小红书风格偏爱 emoji、图文并茂和清晰轻松的阅读体验,但装饰不能代替内容。
|
||||
- 一篇只解决一个主要问题;标题、封面、首屏和正文围绕同一获得感并真正兑现。不编造亲历、身份、数字、效果或用户反馈,材料不足时用第二人称、场景化讲解或中性叙述。
|
||||
- 飞书源稿禁止使用 `callout`;生成后通过 Draft Parse Gate 的 `profile.blocks` 检查,其他 block 按真实信息关系选择。
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用户明确要“小红书笔记、小红书写法、小红书 style、红书感、XHS 风格”时使用,内容保存在哪里不影响本合同生效。
|
||||
|
||||
仅把小红书作为研究对象、数据源或业务渠道时不触发:小红书运营方案走 Workplace,平台数据或竞品分析走 Report,规则说明走 Knowledge。若同时要小红书风格稿和正式体裁,分别生成,不混写。
|
||||
|
||||
## 笔记主任务
|
||||
|
||||
| 主任务 | 内容脊柱 |
|
||||
|-|-|
|
||||
| 教程 / 攻略 / 知识 | 痛点场景 → 核心判断 → 分步做法 → 易错点 / 限制 → 马上可做的一步 |
|
||||
| 体验 / 测评 / 探店 | 使用场景 → 具体观察 → 亮点与槽点 → 适合谁 / 不适合谁 → 选择建议 |
|
||||
| 观点 / 热点 | 争议或反差 → 核心判断 → 理由与例子 → 另一面 / 边界 → 留给读者的问题 |
|
||||
| 个人经历 / 成长 | 真实困扰 → 转折瞬间 → 做过什么 → 可观察变化 → 可迁移认识 |
|
||||
| 推荐 / 种草 / 活动 | 目标人群与场景 → 核心价值 → 具体理由 / 体验 → 使用条件与取舍 |
|
||||
|
||||
## 成稿要求
|
||||
|
||||
- 先钉住具体读者、场景与获得感;内部比较搜索清晰型、痛点共鸣型、反差好奇型 3 个标题,成稿只输出正文能兑现的最强一个。
|
||||
- 首屏用 1—3 个短段落完成“具体场景 / 冲突 → 核心判断 → 内容预告”,不从宏大背景或自我介绍讲起。
|
||||
- 正文用短段落和有意义的小标题按信息增量推进;每节新增动作、观察、例子、判断或限制。“活人感”来自具体细节、选择和取舍,不靠强塞网感词。
|
||||
- emoji 可比正式体裁用得更积极,用于导航、语气和停顿,但不连续堆叠。围绕一个视觉中心设计封面,图片 / 截图 / 示意图就近服务对应内容;无可用图片时给出简短配图建议,正文仍须独立可读。
|
||||
- 核心主题词自然出现在标题或首屏,相关表达按需进入小标题和正文;话题标签少而相关,不为覆盖关键词而复读。
|
||||
- 结尾用一句记忆点收束;互动问题可选且至多一个,不要求固定收尾动作。
|
||||
|
||||
## 交付前检查
|
||||
|
||||
确认读者能一眼判断“这和我有关”,标题承诺已兑现,每节都有实质信息,手机上容易扫读,emoji 与图片确实帮助理解。出现公文腔、长铺垫、文字墙、题文错配、空情绪或虚构事实时返工。
|
||||
@@ -1,99 +0,0 @@
|
||||
# Lark Doc Authoring
|
||||
|
||||
本文件定义从零创作,以及对已有正文进行改写、润色、重组、补写和排版的流程。根 `SKILL.md` 负责场景与格式路由;本文件负责内容判断;格式文件定义表达语法;`create` / `update` 定义写入操作。
|
||||
|
||||
## Philosophy
|
||||
|
||||
文档是为读者服务的信息传递,不是作者的自我表达。唯一标准是:读者能否以最低成本获取所需信息并形成正确理解。
|
||||
|
||||
- **读者本位**:落地前先回答:读者是谁、为什么要读、带着什么任务来。按读者的任务组织内容,不按功能或作者视角罗列。
|
||||
- **结构先行**:结论先行,先整体后局部;按逻辑分组与递进,依据关系选择列表、步骤或表格,使内容便于扫读。(特殊体裁除外)
|
||||
- **极简表达**:默认使用能清楚表达关系的最简单形式;在不损失信息的前提下压缩文字;删冗余,用短句、动词和数据,在文字难以说清流程、交互或层级时用图。
|
||||
- **表达一致**:同一对象、动作和状态全文同名;标题层级与编号采用统一体系。用户提供样例或已有文档时,在不违反更高优先级规则的前提下延续其有效结构、语气、术语和编号。
|
||||
- **约束栈**:用户硬约束 > 读者任务 > 内容 > 组件样式;后项不得牺牲或放宽前项,格式与组件不得反向改变内容判断。
|
||||
|
||||
## Step Plan
|
||||
|
||||
**CRITICAL:按下述步骤,step by step 严格执行,不可跳过任何步骤。**
|
||||
|
||||
### Step_1:深度理解读者任务、文档格式要求、硬约束和禁区。
|
||||
|
||||
### Step_2:选择一个 genre content contract;
|
||||
|
||||
- 读取高置信命中的最多一个 Profile 和最多一个 Adapter,并把实际适用的义务写回 Brief;未命中就保持 `none`。
|
||||
- contract 决定内容任务、证据和体裁边界;adapter 只调整与所选 contract 兼容的平台结构、语气和组件。
|
||||
|
||||
| Content Profile | 独特专业任务 |
|
||||
|-|-|
|
||||
| [`route-workplace.md`](genres/route-workplace.md) | 组织决策、执行、留档 |
|
||||
| [`route-report.md`](genres/route-report.md) | 数据、研究和证据形成洞察 |
|
||||
| [`route-knowledge.md`](genres/route-knowledge.md) | 理解、自学、一次已知操作或检索 |
|
||||
| [`route-media.md`](genres/route-media.md) | 独立采集、核实和公共理解 |
|
||||
| [`route-opinion.md`](genres/route-opinion.md) | 形成并论证判断 |
|
||||
| [`route-consumer.md`](genres/route-consumer.md) | 以真实体验或测试辅助消费选择 |
|
||||
| [`route-marketing.md`](genres/route-marketing.md) | 组织授权的认知、转化或公关内容 |
|
||||
| [`route-personal-brand.md`](genres/route-personal-brand.md) | 本人经历、能力和作品的可信呈现 |
|
||||
| [`route-creative.md`](genres/route-creative.md) | 角色、冲突、情节与分支叙事 |
|
||||
|
||||
| Adapter | 渠道 |
|
||||
|-|-|
|
||||
| [`route-platform.md`](genres/route-platform.md) | Email、微信公众号、小红书 |
|
||||
|
||||
### Step 3:在生成草稿前完成 Presentation Decision,并使用下方 JSON 结构记录关键决策,**在思维链里显示输出**。
|
||||
```json
|
||||
{
|
||||
"target": "",
|
||||
"genre_contract": "",
|
||||
"adapter": "",
|
||||
"presentation_mode": "当 contract 为 none 时,默认 rich 模式;",
|
||||
"hard_rule": "",
|
||||
"visual_plan": {
|
||||
"reason": "解释是否需要图片、画板等组件,以及为什么选择该组件。",
|
||||
"img_enabled": "",
|
||||
"whiteboard_enabled":""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
1. presentation_mode 有三种选择:
|
||||
- **`formal`(表达非常正式)**:庄重、准确、简洁、直接,结构与措辞服从正式规范;默认只使用短段落、列表和普通链接等基础结构;只使用 contract 明示允许或限用的 block,不以 rich block(callout,emoji)、颜色或装饰制造正式感。
|
||||
- **`normal`(正常)**:用最简单的清楚表达;扩展组件必须降低理解、执行或出错成本。
|
||||
- **`rich`(表达非常丰富)**:文风或语气可以更鲜明,**必须主动**扫描图片和画板机会,并优先采用高价值且通过预检的候选;不以 emoji、颜色或组件数量冒充丰富,也不设置固定配额。
|
||||
2. visual_plan:
|
||||
- **一图胜千言**:图片和画板可承载远多于纯文字的视觉信息,具有很高的语义价值,应该考虑优先使用,且能极大降低人类的理解成本。
|
||||
- 当信息满足下列条件时,可以考虑用相关组件表达,并解释为什么选择该组件。
|
||||
|
||||
| 信息关系 | 通常合适的表达 |
|
||||
|-|-|
|
||||
| 同一组字段的精确比较或映射 | 表格 |
|
||||
| 流程、路线、依赖、分支、时序、层级、因果、空间、拓扑、概念关联,或需要整体把握的结构与关系 | 画板 |
|
||||
| 对象、场景、环境、界面、外观、氛围、风格、空间感、概念意象、示例或视觉证据,以及需要建立直观感受的内容 | 图片 |
|
||||
| 两组简短、等权且适合横向阅读的信息 | grid |
|
||||
| 单个关键提醒或限制 | callout |
|
||||
| 简单并列、步骤或连续论述 | 列表或段落 |
|
||||
|
||||
### Step 4:根据初步要求,收集更多资料
|
||||
|
||||
1. 当现有信息不足时,必须补充更多资料,不能直接创建。可以重新从互联网、数据库、文件等来源获取,补充完整信息。
|
||||
2. 当需要图片时,必须**及时**把图片拉取到本地,后续在草稿中引用本地图片。
|
||||
|
||||
### Step 5:读取 [`lark-doc-xml.md`](lark-doc-xml.md),创建任务独占的临时 XML,并结合上述规则和 Philosophy 原则生成 release candidate。使用扩展标签时按需读取 [`lark-doc-xml-extended-blocks.md`](lark-doc-xml-extended-blocks.md)。
|
||||
|
||||
1. 在将任何 XML 草稿写入磁盘前,选取一个不带 `.xml` 的可移植文件名(例如 `draft`),执行 `lark-cli docs +script --command create-temp-xml --file-name "<文件名>" --format json`。
|
||||
2. 把返回的 `data.path` 记为本任务的 `draft_path`,只向该文件写入 release candidate。不得自行去掉目录中的随机值、复用已存在文件或使用其他任务的 `draft_path`。
|
||||
3. 若明确命中 Markdown 例外,不创建 XML 文件,但仍须使用当前任务独占的随机 Markdown 文件名。
|
||||
4. 如果发现 XML 文件存在语法错误,不要全局覆盖重写,使用局部 patch 修复。
|
||||
|
||||
### Step 6: 执行 Draft Parse Gate,并结合返回的 profile 检查当前稿件。
|
||||
- **解析**:对 XML release candidate 执行 `lark-cli docs +script --command parse --content "@<draft_path>" --format json`。命令必须成功,并根据结果校验 block 类型和字数等指标。
|
||||
- **读者与范围**:内容服务读者任务,核心命题和交付范围清楚,没有与读者任务无关的章节。
|
||||
- **结构与体裁**:各部分关系和顺序合理;采用高置信度路由时,稿件满足 contract 与可选 adapter 的要求,无缺项、重复或近邻体裁混用;
|
||||
- **表达**:表达具体、简练、术语一致;需要连贯论述的内容没有被拆成零散列表;标题、列表、表格和编号各司其职,并符合所选 mode、可选 contract 与 adapter。
|
||||
- **一致性**:检查完整标题树;同一目录体系内的同级标题必须统一带或不带序号,编号格式、层级关系和顺序一致、连续,不得局部换制或跳级;颜色与视觉强调保持统一语义;同一对象、动作、状态和专有名词全文同名。
|
||||
- **字数与硬约束**:用户硬约束全部满足;有明确字数或字符数要求时,以 `profile.word_count` / `profile.char_count` 的实测值为准,不自行估算。
|
||||
- **Block 组件与内容**:实际类型满足可选 contract 与 adapter 声明的允许、限用和禁止条件;rich block 服务所选 mode 和真实信息关系。
|
||||
- **处理未通过项**:可用当前材料修复时直接修订;缺少关键事实或资料时,可在获得授权后检索补充;。
|
||||
|
||||
### Step 7: 只有最新 release candidate 解析成功、用户硬约束与质量检测全部通过,才读取 [`lark-doc-create.md`](lark-doc-create.md),按其中的 create 规则使用同一个 `draft_path` 执行写入和传输验证。检查命令是否成功、业务结果是否成功,并逐项处理 `warnings`;不得只看到文档 URL 就宣布完成。
|
||||
|
||||
### Step 8: 无论创建成功、失败或被阻塞,都使用当前运行时的文件删除能力精确删除本任务的 `draft_path` 及其随机父目录;不要依赖平台专用的 `rm` / `del`,也不要使用通配符。最终只交付用户需要的结果,并说明必要来源、未关闭缺口、异常、失败或阻塞原因,以及文档 URL 或 token。
|
||||
@@ -1,15 +1,25 @@
|
||||
# docs +create(创建飞书云文档)
|
||||
|
||||
从 XML(默认)或 Markdown 内容创建一个新的飞书云文档;语义创作默认使用 XML,只有 Authoring 明确判定为 Markdown 例外时才使用 Markdown。
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
|
||||
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
从 XML(默认)或 Markdown 内容创建一个新的飞书云文档。
|
||||
|
||||
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,可承载更丰富的结构化内容)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli docs +script --command create-temp-xml --file-name "draft" --format json
|
||||
lark-cli docs +create --doc-format xml --content "@<create-temp-xml 返回的 data.path>"
|
||||
```
|
||||
# 创建 XML 文档(默认格式,推荐)
|
||||
lark-cli docs +create --content '<title>项目计划</title><h1>目标</h1><p>记录本周重点。</p>'
|
||||
|
||||
单次内容优先使用 `--content -` 从 stdin 读取。XML 使用 `@file` 时,必须先给 `create-temp-xml` 传入不带扩展名的文件名;命令会原子创建 `<名称>_<随机值>_folder/<名称>.xml`,再把稿件写入返回的 `data.path`。不得去掉随机值、复用已存在目录或使用其他任务返回的路径。`@file` 只接受当前工作目录下的相对路径,且参数必须整体加引号,例如 `"@<data.path>"`。明确命中 Markdown 例外时才创建任务独占的临时 Markdown 文件。完成 Deliver 的传输验证后,只清理本任务创建的文件及其随机父目录,不得使用通配符清理,也不得依赖平台专用的 `rm` / `del` 命令。
|
||||
# 仅当用户明确要求导入 Markdown 时才使用;文档标题用 --title,正文标题按内容自然组织
|
||||
lark-cli docs +create --doc-format markdown --title "项目计划" --content $'## 目标\n\n- 明确重点\n- 记录待办'
|
||||
```
|
||||
|
||||
## 返回值
|
||||
|
||||
@@ -32,11 +42,6 @@ lark-cli docs +create --doc-format xml --content "@<create-temp-xml 返回的 da
|
||||
|
||||
- **`document.new_blocks`**:本次操作新增的 block 列表(如画板)。`block_id` 可用于 `docs +update` 的 `--block-id` 做精确编辑;`block_token` 是资源块(如画板)的 token,可交给 `lark-whiteboard` 等 skill 继续操作
|
||||
|
||||
## 结果处理与退出条件
|
||||
|
||||
1. 检查命令是否成功、业务结果是否成功,并逐项处理 `warnings`;不得只看到文档 URL 就宣布完成。
|
||||
2. 传输不一致或存在未处理降级时,基于 fetch 结果修复并重新验证;实际结果与已批准版本一致后才结束。
|
||||
|
||||
> \[!IMPORTANT]
|
||||
> 如果文档是**以应用身份(bot)创建**的,如 `lark-cli docs +create --as bot` 在文档创建成功后,CLI 会**尝试为当前 CLI 用户自动授予该文档的 `full_access`(可管理权限)**。
|
||||
>
|
||||
@@ -51,11 +56,25 @@ lark-cli docs +create --doc-format xml --content "@<create-temp-xml 返回的 da
|
||||
|
||||
## 参数
|
||||
|
||||
|参数|必填|说明|
|
||||
|-|-|-|
|
||||
|`--title`|否|文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `<title>...</title>`;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示|
|
||||
|`--content`|视情况|文档内容(XML 或 Markdown 格式);不传 `--content` 时必须传 `--title`|
|
||||
|`--reference-map`|否|结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、任务独占目录内的相对 `@file`,或 `-` 从 stdin 读取。|
|
||||
|`--doc-format`|否|CLI 与语义创作均默认 `xml`,并建议显式传入;仅用户明确要求 Markdown 或保真导入 Markdown 时使用 `markdown`。单次内容禁止混用两种语法。|
|
||||
|`--parent-token`|否|父文件夹或知识库节点 token(与 `--parent-position` 互斥)|
|
||||
|`--parent-position`|否|父节点位置,如 `my_library`(与 `--parent-token` 互斥)|
|
||||
| 参数 | 必填 | 说明 |
|
||||
| ------------------- | -- |---------------------------------------------|
|
||||
| `--title` | 否 | 文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `<title>...</title>`;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示 |
|
||||
| `--content` | 视情况 | 文档内容(XML 或 Markdown 格式);不传 `--content` 时必须传 `--title` |
|
||||
| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
|
||||
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
|
||||
| `--parent-token` | 否 | 父文件夹或知识库节点 token(与 `--parent-position` 互斥) |
|
||||
| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- **较长文档**:参考 [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) 先建骨架再分段写入;短文档可一次写完整内容
|
||||
- **表达形式**:由用户目标和内容决定。需要结构化表达时可参考 [`lark-doc-style.md`](style/lark-doc-style.md),但不要默认套用固定开头、固定富 block 比例或固定图表
|
||||
|
||||
## 参考
|
||||
|
||||
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
||||
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档
|
||||
- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
|
||||
|
||||
@@ -1,78 +1,81 @@
|
||||
# docs +fetch(读取飞书云文档)
|
||||
|
||||
读取整篇文档,或按目录、章节、区间和关键词获取局部内容。
|
||||
# docs +fetch(获取飞书云文档)
|
||||
|
||||
## 常用示例
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 读取整篇文档
|
||||
lark-cli docs +fetch --doc "文档URL或token"
|
||||
# 获取文档(默认 XML,simple)
|
||||
lark-cli docs +fetch --doc "https://xxx.feishu.cn/docx/Z1Fj...tnAc"
|
||||
|
||||
# 按 URL 中的 #share 锚点局部读取
|
||||
lark-cli docs +fetch --doc '文档URL#share-anchor'
|
||||
# Markdown 格式
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc --doc-format markdown
|
||||
|
||||
# 按关键词定位
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc --scope keyword --keyword "部署|发布|上线"
|
||||
# 带 block ID(用于后续 block 级更新)
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc --detail with-ids
|
||||
|
||||
# 先查看目录,再读取指定章节
|
||||
# 只拿目录
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc --scope outline --max-depth 3
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc --scope section --start-block-id blkTitle
|
||||
|
||||
# 按 block id 区间精读
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc --scope range --start-block-id blkA --end-block-id blkB --detail with-ids
|
||||
|
||||
# URL 带 #share 选区锚点时自动局部读取
|
||||
lark-cli docs +fetch --doc 'docURL#share-anchor'
|
||||
|
||||
# 读整个章节(以标题 id 为锚点,自动展开到下一个同级/更高级标题前)
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc \
|
||||
--scope section --start-block-id <标题id> --detail with-ids
|
||||
|
||||
# 按关键词定位(多关键词用 | 分隔,任一命中即返回)
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc \
|
||||
--scope keyword --keyword "部署|发布|上线"
|
||||
```
|
||||
|
||||
## 参数
|
||||
## 选 `--detail`(每块详细度)
|
||||
|
||||
|参数|必填|说明|
|
||||
|-|-|-|
|
||||
|`--doc`|是|文档 URL 或 token,支持 `/docx/`、`/wiki/` 和带 `#share-...` 的选区链接|
|
||||
|`--doc-format`|否|`xml`(默认)\| `markdown` \| `im-markdown`(供后续 `lark-im` 场景使用)|
|
||||
|`--detail`|否|`simple`(默认)\| `with-ids` \| `full`|
|
||||
|`--revision-id`|否|文档版本号;`-1` 表示最新版本(默认)|
|
||||
|`--scope`|否|`outline` \| `range` \| `keyword` \| `section`;省略则读取整篇|
|
||||
|`--start-block-id`|否|`range` 的起点,或 `section` 的锚点(`section` 必填)|
|
||||
|`--end-block-id`|否|`range` 的终点;`-1` 表示读到末尾|
|
||||
|`--keyword`|否|`keyword` 模式的关键词;支持多级自动匹配和多分支 OR|
|
||||
|`--context-before`|否|返回命中项之前的顶层兄弟块数量(默认 `0`)|
|
||||
|`--context-after`|否|返回命中项之后的顶层兄弟块数量(默认 `0`)|
|
||||
|`--max-depth`|否|`outline` 表示标题层级上限;其它模式表示子树深度(默认 `-1`,不限)|
|
||||
|`--format`|否|`json`(默认)\| `pretty`|
|
||||
| 意图 | `--detail` | 说明 |
|
||||
|------|-----------|------|
|
||||
| **只读**:浏览或总结文档内容 | `simple`(默认) | 简洁 XML/Markdown,不含 block ID、样式属性、引用元数据 |
|
||||
| **定位**:需要 block ID 与其他业务交互 | `with-ids` | 包含 block ID(如 `<p id="blkcnXXXX">`),可用于 `+update` 的 `--block-id`,也可用于拼接 `文档URL#block_id` 形式的直达链接 |
|
||||
| **编辑**:任何修改文档内容的需求 | `full` | 包含 block ID + 样式属性 + 引用元数据,提供完整文档结构信息 |
|
||||
|
||||
## 选择详细度:`--detail`
|
||||
## 选 `--scope`(读取范围)
|
||||
|
||||
|目的|取值|返回内容|
|
||||
|-|-|-|
|
||||
|浏览、总结|`simple`(默认)|简洁 XML/Markdown,不含 block ID、样式和引用元数据|
|
||||
|定位、跳转|`with-ids`|包含 block ID,可用于 `+update --block-id`,也可拼成 `文档URL#block_id` 直达链接|
|
||||
|编辑文档|`full`|包含 block ID、样式和引用元数据,保留完整结构信息|
|
||||
`--scope` 和 `--detail` 正交可组合。**省略 `--scope` 即读整篇;获取一小节时优先用局部读取。**
|
||||
|
||||
需要修改文档时使用 `full`;只读场景通常不必获取额外元数据。
|
||||
|
||||
## 选择读取范围:`--scope`
|
||||
|
||||
`--scope` 与 `--detail` 可以组合。优先读取满足任务所需的最小范围;只有确需全文时才省略 `--scope`。
|
||||
|
||||
|模式|适用场景|关键参数|返回行为|
|
||||
| 模式 | 何时用 | 关键参数 | 行为要点 |
|
||||
|-|-|-|-|
|
||||
|`outline`|结构未知,先查看目录|`--max-depth`|扁平列出标题;返回的标题 ID 可作为 `section` 或 `range` 的端点|
|
||||
|`section`|读取某个标题对应的整节|`--start-block-id`(必填)|顶层标题展开到下一个同级或更高级标题之前;容器内节点(含内嵌标题)按最小包容单元返回容器或表格切片|
|
||||
|`range`|已知精确起止位置|`--start-block-id`、`--end-block-id` 至少一个|同一顶层序列按区间切片;同一容器返回整个容器;同一表格返回瘦身切片;跨顶层时完整返回端点所在的顶层块|
|
||||
|`keyword`|只有关键词或模糊线索|`--keyword`(必填)|按最小包容单元返回命中;同一容器的多处命中自动去重,同一表格的多行命中合并为切片|
|
||||
| `outline` | 不知道结构,先看目录 | `--max-depth`(标题层级上限) | 扁平列出所有标题,**包括嵌在容器里的内嵌标题**(如 callout 里的 h3);这些 id 可直接作后续 `section` / `range` 端点 |
|
||||
| `section` | 读某个标题对应的整节 | `--start-block-id`(必填) | 顶层标题 → 展开到下一同级/更高级标题前;容器内节点(含内嵌标题) → 按"最小包容单元"返回容器/表格切片,不做 heading 扩展;顶层非标题块 → 仅该块 |
|
||||
| `range` | 已知精确起止 | `--start-block-id` / `--end-block-id` 至少一个;`-1` = 读到末尾 | 两端同顶层 → 顶层序列切片;两端同一容器 → 容器整体;两端同一表格 → 瘦身切片;**跨顶层 → 端点所在顶层块整块输出,不做瘦身** |
|
||||
| `keyword` | 只有模糊关键词 | `--keyword`(**多级自动 fallback**:子串 → 归一化 → 分词形变 → RE2 正则;`\|` 分隔多分支 OR) | 每处命中按"最小包容单元"输出;**自动去重**(同容器多命中 → 单个容器,同表格多行命中 → 合并切片) |
|
||||
|
||||
`keyword` 会依次尝试子串、归一化、分词形变和 RE2 正则匹配。多关键词使用 `|` 表示 OR,例如 `部署|发布|上线`;任一分支命中即返回。
|
||||
> 💡 **多关键词用 `\|` 拼接(OR 语义,任一命中即返回)**:例 `"部署\|发布\|上线"`,三词任一命中都进结果,适合**同义词/别名/多业务术语**一次召回(如 `bug\|缺陷\|故障`)。
|
||||
|
||||
范围参数的共同规则:
|
||||
**设置 `--scope` 时共用** `--context-before` / `--context-after` / `--max-depth`。
|
||||
|
||||
- `--max-depth`:`outline` 中 `3` 表示列出 h1~h3;其它模式中 `0` 表示仅返回块自身,`-1` 表示不限深度。
|
||||
- `--context-before` / `--context-after`:仅对完整的顶层块生效。命中位于容器或表格内时会被忽略;如需更大范围,改用 `section` 或 `range`。
|
||||
- `--max-depth`:`outline` = 标题层级上限(3 = h1~h3);其它模式 = 被选块的子树遍历深度(`-1` 不限,`0` 仅块自身)。
|
||||
- `--context-before/--context-after`:**只对整块顶层单元生效**;命中落在容器/表格内(返回容器或切片)时 before/after 被忽略,需要更大范围改用 `section` / `range` 显式指定。
|
||||
|
||||
推荐选择顺序:
|
||||
**决策顺序**(核心原则:**局部获取优于全量获取**,根据需求形态选起点,必要时多步组合收敛范围):
|
||||
1. 需求**直接给出待查的具体术语/错误码/标识** → 直接走 `keyword` 粗匹配(多级 fallback 自动覆盖形变),需要更大上下文时用返回的 `top-block-id` 走 `section` / `range`
|
||||
2. 需求**指向某个章节/标题**("修改 XX 章"、"总结第 3 节"、"关于 xx 的内容")→ 先 `outline --max-depth 3` 拿目录 → `section --start-block-id <标题id>` 精读
|
||||
3. 已知**精确起止 / 跨节连续区间** → `range`
|
||||
4. **结构未知且无明确关键词/章节线索** → `outline` 探测,再回到 2/3
|
||||
5. **兜底**:仅在确需整篇时才省略 `--scope`;不要为省事直接读整篇
|
||||
|
||||
|已知信息|首选方式|后续动作|
|
||||
|-|-|-|
|
||||
|具体术语、错误码或标识|`keyword`|上下文不足时,用返回的 `top-block-id` 再执行 `section` 或 `range`|
|
||||
|章节或标题|`outline --max-depth 3`|获取标题 ID 后执行 `section`|
|
||||
|精确起止位置|`range`|按需调整端点或深度|
|
||||
|没有关键词,也不了解结构|`outline`|根据目录转入 `section` 或 `range`|
|
||||
|确实需要整篇|省略 `--scope`|—|
|
||||
## 局部读取的输出结构:`<fragment>` 与 `<excerpt>`
|
||||
|
||||
设置 `--scope` 时返回的 `content` 被一个 `<fragment>` 节点包裹,属性包含 `mode` / `requested-start` / `requested-end` / `keyword`(按需)。子节点只有两种形态:
|
||||
|
||||
- **顶层块**:完整块直接作为 `<fragment>` 的子节点,无额外包裹。
|
||||
- **`<excerpt top-block-id="..." parent-block-path="...">`**:非顶层节选(容器整体 / 表格瘦身切片)。
|
||||
- `top-block-id`:所在顶层块 id,想看该块全貌时作 `section` / `range` 锚点再拉一次。
|
||||
- `parent-block-path`:从顶层块到 excerpt 内容直接父节点的 id 路径,`/` 分隔(表格切片时即表格自身 id)。
|
||||
|
||||
**看到 `<excerpt>` 即意味着这是节选**,不能假设看到了该顶层块的全貌。
|
||||
|
||||
**表格默认瘦身**:即便 `<table>` 本身是顶层块也只返回 thead + 命中 tr。想拿整张表 → `range --start-block-id <table-id> --end-block-id <table-id>`;切片范围恰好覆盖全部 tr 时 SDK 自动升级为整块、不包 `<excerpt>`。
|
||||
|
||||
## 返回值
|
||||
|
||||
@@ -82,7 +85,7 @@ lark-cli docs +fetch --doc Z1Fj...tnAc --scope section --start-block-id blkTitle
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"document": {
|
||||
"document_id": "docToken",
|
||||
"document_id": "doxcnXXXX",
|
||||
"revision_id": 12,
|
||||
"content": "<title>标题</title><p>文档内容...</p>",
|
||||
"reference_map": {
|
||||
@@ -97,35 +100,49 @@ lark-cli docs +fetch --doc Z1Fj...tnAc --scope section --start-block-id blkTitle
|
||||
}
|
||||
}
|
||||
```
|
||||
`content` 的格式由 `--doc-format` 决定。`reference_map` 是正文引用数据的结构化 sidecar:一级键 `block_type` 表示引用所在的块类型,二级键 `ref` 对应正文中的临时引用;每个引用的值是由 `real-attr-key` 和 `real-attr-value` 组成的真实属性映射,具体属性由块类型决定。没有提取数据时,`reference_map` 可能为空。`content` 和 `reference_map` 属于同一份响应,保留或回放内容时应配套处理。`tips` 给出安全回放或降级提示。`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
|
||||
|
||||
### 理解局部读取结果
|
||||
`content` 的格式由 `--doc-format` 决定。`reference_map` 是正文引用数据的结构化 sidecar:一级键 `block_type` 表示引用所在的块类型,二级键 `ref` 对应正文中的临时引用;每个引用的值是由 `real-attr-key` 和 `real-attr-value` 组成的真实属性映射,具体属性由块类型决定。没有提取数据时,`reference_map` 可能为空。`content` 和 `reference_map` 属于同一份响应,保留或回放内容时应配套处理。`tips` 给出安全回放或降级提示。`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
|
||||
|
||||
## 参数
|
||||
|
||||
设置 `--scope` 后,`content` 外层是 `<fragment>`,并按需携带 `mode`、`requested-start`、`requested-end` 或 `keyword` 属性。其子节点有两种形式:
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--doc` | 是 | 文档 URL 或 token(支持 `/docx/` 和 `/wiki/`) |
|
||||
| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `im-markdown`(仅用于获取内容后在 `lark-im` 场景下使用) |
|
||||
| `--detail` | 否 | `simple`(默认)\| `with-ids` \| `full` |
|
||||
| `--revision-id` | 否 | 文档版本号,`-1` = 最新(默认) |
|
||||
| `--scope` | 否 | `outline` \| `range` \| `keyword` \| `section`(省略 = 读整篇) |
|
||||
| `--start-block-id` | 否 | `range`/`section` 起始/锚点 id(`section` 必填) |
|
||||
| `--end-block-id` | 否 | `range` 结束 id;`-1` 表示读到末尾 |
|
||||
| `--keyword` | 否 | `keyword` 模式关键词,**4 层自动 fallback**(子串 → 归一化 → 分词形变 → RE2 正则);`\|` 分隔多分支 OR |
|
||||
| `--context-before` | 否 | 命中前拉几个兄弟块(仅对顶层单元生效,默认 `0`) |
|
||||
| `--context-after` | 否 | 命中后拉几个兄弟块(仅对顶层单元生效,默认 `0`) |
|
||||
| `--max-depth` | 否 | `outline` = 标题层级上限;其它 = 子树深度(`-1` 不限,默认) |
|
||||
| `--format` | 否 | `json`(默认)\| `pretty` |
|
||||
|
||||
- **顶层块**:直接作为 `<fragment>` 的子节点,表示返回了完整块。
|
||||
- **`<excerpt top-block-id="..." parent-block-path="...">`**:表示只返回了容器或表格中的节选。
|
||||
- `top-block-id` 是节选所在的顶层块 ID。需要查看完整块时,可将它作为 `section` 或 `range` 的锚点重新读取。
|
||||
- `parent-block-path` 是从顶层块到节选内容直接父节点的 ID 路径,以 `/` 分隔;表格切片中即表格自身 ID。
|
||||
## 图片、文件、画板的处理
|
||||
|
||||
看到 `<excerpt>` 时,不要假设已经获取了整个顶层块。
|
||||
**文档中的素材以 XML 标签形式出现:**
|
||||
|
||||
表格默认瘦身:即使 `<table>` 本身是顶层块,也只返回表头和命中的行。读取整张表时,使用 `range --start-block-id <table-id> --end-block-id <table-id>`。如果切片覆盖全部数据行,SDK 会自动返回完整表格,不再包裹 `<excerpt>`。
|
||||
```xml
|
||||
<img token="..." url="https://..." width="..." height="..."/>
|
||||
<source token="..." url="https://..." name="skills.zip"/>
|
||||
<whiteboard token="..."/>
|
||||
```
|
||||
|
||||
## 处理文档内嵌资源
|
||||
- `<img>` / `<source>` 带 `url` 时,直接用该 URL 下载即可(普通 HTTP GET),无需走 shortcut。
|
||||
- 没有 `url`、或只想预览 → `docs +media-preview --token <token> --output ./preview_media`
|
||||
- 明确下载,或目标是 `<whiteboard>`(画板只能走 shortcut) → `docs +media-download --token <token> --output ./downloaded_media`
|
||||
- 文档封面图不是正文素材;下载/更新/删除封面图 → `docs +resource-download/+resource-update/+resource-delete --type cover`
|
||||
|
||||
|返回内容|处理方式|
|
||||
|-|-|
|
||||
|`<img>`、`<source>`|有 `url` 时直接 HTTP GET;否则提取 `token`,预览用 `docs +media-preview`,下载用 `docs +media-download`|
|
||||
|`<whiteboard>`|提取 `token`,使用 `docs +media-download`|
|
||||
|`<sheet>`、`<cite file-type="sheets">`|提取 `token` 和 `sheet-id`,转到 [`lark-sheets`](../../lark-sheets/SKILL.md)|
|
||||
|`<bitable>`、`<cite file-type="bitable">`|提取 `token` 和 `table-id`,转到 [`lark-base`](../../lark-base/SKILL.md)|
|
||||
|`<vc-transcribe-tab>`|提取 `vc-node-id`,使用 [`lark-note`](../../lark-note/SKILL.md) 的 `note +detail`|
|
||||
|`<synced_reference>`|提取 `src-token` 和 `src-block-id`,读取源文档并定位 block|
|
||||
## 嵌入电子表格 / 多维表格
|
||||
|
||||
返回中可能含 `<sheet>`、`<bitable>`、`<cite file-type="sheets|bitable">`。内部数据无法通过 `docs +fetch` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-doc-create](lark-doc-create.md) — 创建文档
|
||||
- [lark-doc-update](lark-doc-update.md) — 更新文档
|
||||
- [lark-doc-media-preview](lark-doc-media-preview.md) — 预览素材
|
||||
- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材或画板缩略图
|
||||
- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图
|
||||
- [lark-doc-resource-cover](lark-doc-resource-cover.md) — 读取、更新、删除文档封面图
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# `docs +script`
|
||||
|
||||
`docs +script` 可创建名称唯一的临时 XML、解析并统计本地内容或在线文档,也可在本地转换格式。`--command parse` 必须且只能提供一种输入:用 `--doc` 传文档 URL / token,或用 `--content` 传字面内容、`@当前目录下的相对路径`、`-`(stdin)。`--format json` 只控制 CLI 输出格式,不表示输入格式。
|
||||
|
||||
## 创建唯一的临时 XML
|
||||
|
||||
在准备 XML 草稿前先原子创建空文件,并读取返回的 `data.path`:
|
||||
|
||||
```bash
|
||||
lark-cli docs +script --command create-temp-xml --file-name "川西" --format json
|
||||
```
|
||||
|
||||
成功时返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"path": "川西_123456789_folder/川西.xml"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `--file-name` 输入不带 `.xml` 扩展名的可移植名称。命令创建 `<名称>_<随机值>_folder/<名称>.xml`;例如输入 `川西`,返回 `川西_123456789_folder/川西.xml`。
|
||||
- `path` 是当前工作目录下的相对路径,也是成功结果中的唯一业务字段,可直接组成 `--content "@<data.path>"`。
|
||||
- 随机目录通过原子操作创建;并发任务不得自行去掉随机值、改成固定目录,也不得复用另一个任务返回的路径。
|
||||
- 该能力直接使用 CLI 的跨平台文件接口,不依赖 Unix `mktemp` 或 Windows 专用命令。
|
||||
- 名称不得包含路径分隔符、Windows 保留字符或设备名。`create-temp-xml` 不接受 `--content`、`--doc`、`--output` 或 `--overwrite`。文件初始为空;写入、解析和文档创建结束后,精确删除 `data.path` 及其随机父目录。
|
||||
|
||||
## 解析 XML 或 Markdown
|
||||
|
||||
使用同一条 `parse` 指令。shortcut 根据内容自动识别 XML 或 Markdown,调用方不需要判断或声明输入格式:
|
||||
|
||||
```bash
|
||||
lark-cli docs +script --command parse --doc "<文档 URL 或 token>" --format json
|
||||
lark-cli docs +script --command parse --content "@document.xml" --format json
|
||||
lark-cli docs +script --command parse --content "@document.md" --format json
|
||||
```
|
||||
|
||||
`--doc` 支持裸 Docx token,以及包含 `/docx/` 或 `/wiki/` 的文档 URL。该模式会联网读取 XML 后直接解析,不需要先执行 `docs +fetch` 或创建临时文件,并需要 `docx:document:readonly` 权限。本地 `--content` 模式不发起 OpenAPI 请求。`--doc` 不适用于 `markdown-to-xml`。
|
||||
|
||||
XML 输入执行严格解析,但为与服务端 SDK 对齐,带引号的属性值允许裸 `&` 并按字面值解析(例如 URL 查询参数 `...?seed=lark-cli&raw=1`);完整的未知实体(如 `&unknown;`)、不完整标签、错误嵌套、非法属性或不支持的 LarkOpenCLI 标签仍会返回非零退出码。Markdown 输入按 LarkOpenCLI Markdown 语义解析。资源块内部未出现在输入文本中的内容不计入字数或字符数。
|
||||
|
||||
成功时 `data` 只包含 `profile`:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"profile": {
|
||||
"word_count": 10,
|
||||
"char_count": 15,
|
||||
"block_count": 2,
|
||||
"blocks": [
|
||||
{"type": "p", "count": 1, "ratio": 0.5},
|
||||
{"type": "title", "count": 1, "ratio": 0.5}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `data.profile.word_count`:语义字数。统计汉字、英文单词 / URL / code path、数字、中文标点和独立可见符号;英文单词内部按一个语义单位计算。
|
||||
- `data.profile.char_count`:可见字符数,不含空格;统计汉字、英文字母、数字、中英文标点和可见符号,非 BMP 符号按 UTF-16 code unit 计算。
|
||||
- `data.profile.block_count`:block 总数。
|
||||
- `data.profile.blocks[]`:每种 block 的 `type`、`count` 和 `ratio`;`ratio = count / block_count`。
|
||||
|
||||
## Markdown 转 XML
|
||||
|
||||
`markdown-to-xml` 只负责把 Markdown 转成 LarkOpenCLI XML:
|
||||
|
||||
```bash
|
||||
lark-cli docs +script --command markdown-to-xml --content "@document.md" --format json
|
||||
```
|
||||
|
||||
成功时 `data` 只包含转换结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"xml": "<h1>标题</h1><p>正文</p>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
该指令不返回 `profile`。需要统计原 Markdown 时,独立执行 `--command parse`。
|
||||
@@ -1,79 +1,174 @@
|
||||
|
||||
# docs +update(更新飞书云文档)
|
||||
|
||||
使用文本或 block 指令精确更新飞书云文档。默认使用 XML;仅在用户明确要求或必须保真 Markdown 时使用 Markdown。
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
|
||||
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
## 常用示例
|
||||
通过八种指令精确更新飞书云文档。支持字符串级别和 block 级别的操作。
|
||||
|
||||
```bash
|
||||
# 先定位内容并获取最新 block ID
|
||||
lark-cli docs +fetch --doc "文档URL或token" --scope keyword --keyword "key1|key2" --detail with-ids
|
||||
|
||||
# 替换文本;--content "" 可删除文本
|
||||
lark-cli docs +update --doc "xx" --command str_replace --pattern "旧内容" --content "新内容"
|
||||
|
||||
# 替换或插入 block
|
||||
lark-cli docs +update --doc "xx" --command block_replace --block-id blkTarget --content '<p>新段落</p>'
|
||||
lark-cli docs +update --doc "xx" --command block_insert_after --block-id blkAnchor --content '<h2>新章节</h2><p>章节内容</p>'
|
||||
|
||||
# 删除多个 block
|
||||
lark-cli docs +update --doc "xx" --command block_delete --block-id "blkA,blkB"
|
||||
```
|
||||
|
||||
## 推荐流程
|
||||
|
||||
1. **Observe(读取现状)**:先 `docs +fetch` 读取当前文档状态,并按意图选择最小范围。
|
||||
- 改某一节或大文档:先 `--scope outline --max-depth 2` 找章节,再 `--scope section --start-block-id <标题id> --detail with-ids`
|
||||
- 精确跨节区间:用 `--scope range --start-block-id xxx --end-block-id yyy`
|
||||
- 只有模糊关键词:用 `--scope keyword --keyword "key1|key2" --context-before 1 --context-after 1 --detail with-ids`
|
||||
- 明确整篇重构才读 `--detail with-ids` 全文;只读摘要或确认事实时用更轻的 fetch
|
||||
2. **Diagnose(诊断问题)**:判断用户目标、当前结构、语气、重复、断流、事实口径和需要保留的资源;识别哪些 block 必须原样保留。
|
||||
3. **Patch Plan(制定局部计划)**:把修改拆成最小安全操作:简单行内替换用 `str_replace`;整段/整块重写用 `block_replace`;增补章节用 `block_insert_after`;删冗余用 `block_delete`;调整顺序用 `block_move_after`。
|
||||
4. **Patch(精确修改)**:按 block / section 执行局部命令。保护 `<cite>`、`<img>`、`<source>`、`<whiteboard>`、`<sheet>`、`<bitable>`、`<synced_reference>` 等 token 化内容,不要改成纯文本或占位符。同一 block 的多处修改合并成一次 `block_replace`。
|
||||
5. **Verify(fetch 验证)**:每轮写操作后按影响范围重新 fetch,检查用户要求、结构、语气、事实、资源块和 block ID 是否符合预期;不满足就基于最新 fetch 结果继续 Diagnose / Patch,不要沿用上一轮 block ID。
|
||||
|
||||
除非用户明确要求完全重建,或原文已无保留价值,否则不要使用 `overwrite`;它可能丢失评论和暂不支持的资源。
|
||||
|
||||
## 生成 block 直达链接
|
||||
|
||||
用户需要某个 block 的直达链接时,只定位 block,不执行文档写操作:
|
||||
|
||||
1. 使用局部 `docs +fetch --detail with-ids` 获取目标 `block_id`。
|
||||
2. 返回 `文档基础 URL#block_id`;没有 `block_id` 时不得猜测。
|
||||
> **⚠️ 格式选择规则:**
|
||||
> - **局部精修**(`str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after`):优先使用 XML(默认)。XML 能稳定表达 block 结构和样式,精准编辑更可控;不要因为 Markdown 写起来更简单就自行切换。
|
||||
> - **整段写入**(`append` / `overwrite`):XML 和 Markdown 都可以。用户提供 `.md` 本地文件或明确要求 Markdown 时直接用 Markdown;否则默认 XML。
|
||||
>
|
||||
> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID,也无样式(颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --detail with-ids` **配合 `--scope`(`outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown,只是写入内容不带样式。
|
||||
|
||||
## 参数
|
||||
|
||||
|参数|必填|说明|
|
||||
|-|-|-|
|
||||
|`--doc`|是|文档 URL 或 token|
|
||||
|`--command`|是|更新指令,见下表|
|
||||
|`--doc-format`|否|`xml`(默认)或 `markdown`|
|
||||
|`--content`|视指令|写入内容;`str_replace` 传空字符串可删除文本|
|
||||
|`--pattern`|视指令|`str_replace` 的匹配文本|
|
||||
|`--block-id`|视指令|目标 block ID;批量删除时用逗号分隔,`-1` 表示末尾,`0` 表示开头|
|
||||
|`--src-block-ids`|视指令|要复制或移动的源 block ID,多个 ID 用逗号分隔|
|
||||
|`--reference-map`|否|保留或回放既有 `reference_map`,需与 `--content` 配合;支持 JSON、任务目录内的相对 `@file` 或 stdin `-`|
|
||||
|`--revision-id`|否|基准版本号,默认 `-1`(最新版本)|
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--doc` | 是 | 文档 URL 或 token |
|
||||
| `--command` | 是 | 操作指令(见下方指令速查表) |
|
||||
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
|
||||
| `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) |
|
||||
| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
|
||||
| `--pattern` | 视指令 | 匹配文本(str_replace) |
|
||||
| `--block-id` | 视指令 | 目标 block ID(block_* 操作),逗号分隔可批量删除,-1 表示末尾 |
|
||||
| `--src-block-ids` | 视指令 | 源 block ID(逗号分隔),用于 block_copy_insert_after / block_move_after |
|
||||
| `--revision-id` | 否 | 基准版本号,-1 = 最新(默认 `-1`) |
|
||||
|
||||
## 指令速查
|
||||
## 指令速查表
|
||||
|
||||
|指令|用途|必需参数|
|
||||
|-|-|-|
|
||||
|`str_replace`|全文查找替换;支持富文本,空 `--content` 表示删除|`--pattern`、`--content`|
|
||||
|`block_insert_after`|在指定 block 后插入内容|`--block-id`、`--content`|
|
||||
|`block_copy_insert_after`|按 ID 顺序复制源 block 到指定位置,源 block 不变|`--block-id`、`--src-block-ids`|
|
||||
|`block_replace`|替换指定 block;同一 block 一次操作中只能替换一次|`--block-id`、`--content`|
|
||||
|`block_delete`|删除一个或多个 block|`--block-id`|
|
||||
|`block_move_after`|将已有 block 移到指定位置|`--block-id`、`--src-block-ids`|
|
||||
|`append`|在文档末尾追加,等价于 `block_insert_after --block-id -1`|`--content`|
|
||||
|`overwrite`|清空后重写全文,丢失图片、评论等内容,非必要不使用|`--content`|
|
||||
| 指令 | 说明 | 必需参数 |
|
||||
|------|------|----------|
|
||||
| `str_replace` | 全文文本查找替换(replacement 支持富文本标签;`--content` 传空字符串即为删除) | `--pattern` `--content` |
|
||||
| `block_insert_after` | 在指定 block 之后插入新内容 | `--block-id` `--content` |
|
||||
| `block_copy_insert_after` | 复制源 block 并插入到锚点之后(源块不变) | `--block-id` `--src-block-ids` |
|
||||
| `block_replace` | 替换指定 block(同一 block 仅限一次) | `--block-id` `--content` |
|
||||
| `block_delete` | 删除指定 block(逗号分隔可批量) | `--block-id` |
|
||||
| `overwrite` | ⚠️ 清空文档后全文重写(可能丢失图片、评论) | `--content` |
|
||||
| `append` | ⚠️ 在文档**末尾**追加内容(等价于 `block_insert_after --block-id -1`)。**不适用于逐章填充**——逐章写入请用 `block_insert_after` 并指定对应标题的 `--block-id` | `--content` |
|
||||
| `block_move_after` | 移动已有 block 到指定位置 | `--block-id` `--src-block-ids` |
|
||||
|
||||
## 安全规则与限制
|
||||
## Block ID 生命周期
|
||||
|
||||
- 每次写操作后都按 block ID 已变化处理。新插入或复制的内容一定使用新 ID;替换、删除和覆盖会使旧 ID 失效;移动会改变章节与 range 语义。
|
||||
- 同一 block 有多处修改时,尽量合并为一次 `block_replace`,避免连续使用旧 ID。
|
||||
- `append` 只适合文末追加。逐章填充应使用 `block_insert_after` 并指定对应标题的 block ID。
|
||||
- `block_move_after` 支持所有块类型。`--block-id -1` 表示文末,0 表示开头,其它 block ID 表示对应锚点之后。
|
||||
- `block_copy_insert_after` 支持基础标签;资源块仅支持 `img`、`source`、`whiteboard`、`sheet`、`chat_card`、`sub-page-list`,不支持 `task`、`bitable`、`base_ref`、`synced_reference`、`synced_source`、`okr`。
|
||||
写操作后不要默认复用之前 fetch 到的 block ID:
|
||||
|
||||
- `overwrite` / `block_replace` / `block_delete`:受影响旧 ID 失效,继续 block 级操作前重新 fetch
|
||||
- `block_insert_after` / `append` / `block_copy_insert_after`:锚点 / 源 ID 通常保留,新内容是新 ID;要操作新内容先重新 fetch
|
||||
- `block_move_after`:被移动 ID 通常保留,但位置、章节、range 语义变化;后续依赖位置时重新 fetch
|
||||
- `str_replace`:简单行内替换通常不改变 ID;跨行 / 大段替换后如继续 block 级操作,先重新 fetch
|
||||
|
||||
## 指令示例
|
||||
|
||||
### str_replace — 全文文本替换
|
||||
|
||||
> **匹配范围:**
|
||||
> - **XML 模式(默认)**:`--pattern` 只支持**行内匹配**,不能跨 block / 跨段落匹配。涉及整段或多 block 的改动,请改用 `block_replace`。
|
||||
> - **Markdown 模式**(`--doc-format markdown`):`--pattern` 同时支持**行内和跨行匹配**,可以用多行字符串匹配并替换一整段内容。
|
||||
> - 还支持**`前缀...后缀` 省略号语法**:用 `...`(三个英文句点)串联起始与结束片段,匹配从前缀到后缀之间的全部内容(含中间被省略部分)。适合一段很长、但首尾特征明显的文本,避免把整段都塞进 `--pattern`。
|
||||
> - 前缀、后缀本身仍遵循 Markdown 转义规则;省略号中间的内容**会被替换**为 `--content` 的完整文本,不会被保留。
|
||||
|
||||
```bash
|
||||
# 简单文本替换
|
||||
lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
--pattern "张三" --content "李四"
|
||||
|
||||
# 替换为富文本(加粗 + 链接)
|
||||
lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
--pattern "旧链接" --content '<b>新链接</b> <a href="https://example.com">点击查看</a>'
|
||||
|
||||
# 仅当用户明确要求时才使用 Markdown
|
||||
lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
--doc-format markdown --pattern "旧内容" --content "新内容"
|
||||
|
||||
# Markdown 模式下支持跨行匹配(--pattern 与 --content 都需要真实换行;"..."/'...' 里的 \n 是字面量)
|
||||
# 多行内容推荐 heredoc 或 --content @file.md,避免 shell 转义踩坑
|
||||
lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
--doc-format markdown \
|
||||
--pattern "$(printf '## 旧标题\n\n第一段原文\n\n第二段原文')" \
|
||||
--content - <<'EOF'
|
||||
## 新标题
|
||||
|
||||
改写后的第一段
|
||||
|
||||
改写后的第二段
|
||||
EOF
|
||||
|
||||
# Markdown 模式下使用 `前缀...后缀` 省略号匹配首尾特征明显的大段内容
|
||||
# 下例会把「## 旧标题」到「结束语。」之间的所有内容整体替换
|
||||
lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
--doc-format markdown \
|
||||
--pattern "## 旧标题...结束语。" \
|
||||
--content - <<'EOF'
|
||||
## 新标题
|
||||
|
||||
重写后的正文...
|
||||
|
||||
新的结束语。
|
||||
EOF
|
||||
|
||||
# 删除文本:--content 传空字符串即可
|
||||
lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
--pattern "废弃的内容" --content ""
|
||||
```
|
||||
|
||||
### block_insert_after — 在指定 block 之后插入
|
||||
|
||||
```bash
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_insert_after \
|
||||
--block-id "目标 block_id" \
|
||||
--content '<h2>新章节</h2><ul><li>要点 1</li><li>要点 2</li></ul>'
|
||||
```
|
||||
|
||||
### block_replace — 替换指定 block
|
||||
|
||||
```bash
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_replace \
|
||||
--block-id "目标 block_id" \
|
||||
--content '<p>替换后的段落内容</p>'
|
||||
```
|
||||
|
||||
### block_delete — 删除指定 block
|
||||
|
||||
```bash
|
||||
# 删除多个块时用逗号 "," 分隔
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_delete \
|
||||
--block-id "block_id_1,block_id_2,block_id_3"
|
||||
```
|
||||
|
||||
### overwrite — 全文覆盖
|
||||
|
||||
```bash
|
||||
lark-cli docs +update --doc "<doc_id>" --command overwrite \
|
||||
--content '<title>全新文档</title><h1>概述</h1><p>新的内容</p>'
|
||||
```
|
||||
|
||||
> ⚠️ 会清空文档后重写,可能丢失图片、评论等。仅在需要完全重建文档时使用。
|
||||
|
||||
### append — 在文档末尾追加
|
||||
|
||||
```bash
|
||||
lark-cli docs +update --doc "<doc_id>" --command append \
|
||||
--content '<h2>新增章节</h2><p>追加的内容</p>'
|
||||
```
|
||||
|
||||
> 等价于 `block_insert_after --block-id -1`,无需先获取 block ID。
|
||||
|
||||
### block_copy_insert_after — 复制块并插入
|
||||
|
||||
将一个或多个源块复制到锚点块之后,源块保持不变。`--src-block-ids` 为逗号分隔的源块 ID,按顺序依次插入到锚点之后。
|
||||
|
||||
```bash
|
||||
# 复制多个块(按顺序插入:anchor → a → b → c)
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_copy_insert_after \
|
||||
--block-id "锚点 block_id" \
|
||||
--src-block-ids "block_a,block_b,block_c"
|
||||
```
|
||||
|
||||
### block_move_after — 移动已有 block
|
||||
|
||||
将文档中已有的 block 移动到指定锚点之后。使用 `--src-block-ids` 指定要移动的块 ID,无需 `--content`。
|
||||
|
||||
```bash
|
||||
# 移动到页面末尾
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_move_after \
|
||||
--block-id "-1表示末尾,page_id表示开头,blk" \
|
||||
--src-block-ids "block_a,block_b"
|
||||
```
|
||||
|
||||
## 返回值
|
||||
|
||||
@@ -83,21 +178,83 @@ lark-cli docs +update --doc "xx" --command block_delete --block-id "blkA,blkB"
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"document": {
|
||||
"revision_id": 2,
|
||||
"revision_id": 13,
|
||||
"new_blocks": [
|
||||
{ "block_id": "blkcnXXXX", "block_type": "whiteboard", "block_token": "boardXXXX" }
|
||||
]
|
||||
},
|
||||
"result": "success",
|
||||
"updated_blocks_count": 1,
|
||||
"updated_blocks_count": 3,
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|字段|说明|
|
||||
|-|-|
|
||||
|`result`|`success` \| `partial_success` \| `failed`|
|
||||
|`updated_blocks_count`|实际更新的 block 数量|
|
||||
|`warnings`|警告列表|
|
||||
|`document.new_blocks`|新增 block;`block_id` 用于后续编辑,资源块的 `block_token` 可交给对应 skill 继续处理|
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `result` | `success` \| `partial_success` \| `failed` |
|
||||
| `updated_blocks_count` | 实际更新的 block 数量 |
|
||||
| `warnings` | 警告信息列表 |
|
||||
| `document.new_blocks` | 本次操作新增的 block 列表(如画板)。`block_id` 可用于后续精确编辑;`block_token` 是资源块 token(如画板)可交给 `lark-whiteboard` 等 skill 继续操作 |
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 精确 block 级更新
|
||||
|
||||
1. **获取文档内容和 block ID**:
|
||||
```bash
|
||||
lark-cli docs +fetch --doc "<doc_id>" --detail with-ids
|
||||
```
|
||||
|
||||
2. **定位目标 block**:从返回的 XML 中找到要修改的 block 及其 `id` 属性
|
||||
|
||||
3. **执行更新**:
|
||||
```bash
|
||||
# 替换特定 block
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_replace \
|
||||
--block-id "blkcnXXXX" --content "<p>新内容</p>"
|
||||
|
||||
# 在某 block 后插入
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_insert_after \
|
||||
--block-id "blkcnXXXX" --content "<h2>追加的章节</h2>"
|
||||
```
|
||||
|
||||
### 简单文本替换
|
||||
|
||||
不需要 block ID,直接匹配替换:
|
||||
|
||||
```bash
|
||||
lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
--pattern "v1.0" --content "v2.0"
|
||||
```
|
||||
|
||||
## 画板处理
|
||||
|
||||
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
|
||||
|
||||
画板的语法选型与插入示例见 [`lark-doc-xml.md`](lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md)。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- **精确操作优于全文覆盖**:使用 `block_replace`/`block_insert_after` 精确修改,避免 `overwrite` 全文覆盖
|
||||
- **str_replace 的匹配范围取决于格式**:
|
||||
- **XML 模式(默认)**:`--pattern` 只支持**行内**匹配,不支持跨行 / 跨 block。段落、整块或容器级(列表、表格、分栏、引用块等)改动请改用 `block_replace` 指定 block_id 重建。
|
||||
- **Markdown 模式**(`--doc-format markdown`):`--pattern` 同时支持**行内和跨行**匹配,还支持 `前缀...后缀` 省略号语法(用 `...` 串联首尾片段匹配一大段内容),可以一次替换多行文本;但仍建议优先按最小片段匹配,跨 block 容器级重写仍优先用 `block_replace`,避免副作用。
|
||||
- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
|
||||
- **str_replace 的 replacement 支持富文本**:可以用行内标签 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本
|
||||
- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
|
||||
- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
|
||||
- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全:
|
||||
1. 用 `block_insert_after` 在目标位置插入新的富文本结构
|
||||
2. 用 `block_delete` 批量删除旧的 block
|
||||
3. 这样可以保留文档中其他不相关的内容(图片、评论等)
|
||||
- **表达形式**:插入或替换内容时,优先沿用用户要求和已有文档风格;需要结构化表达时可参考 [`lark-doc-style.md`](style/lark-doc-style.md),但不要为了固定丰富度主动添加组件
|
||||
|
||||
## 参考
|
||||
|
||||
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
||||
- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档
|
||||
- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
| Skill | 核心职责 | 约束 |
|
||||
|-------------------|-----------------------------------------------------------|---------------------------------|
|
||||
| `lark-doc` | 识别画板机会、使用 Mermaid/SVG 创建图表、调度 SubAgent、插入简单图表或复杂空白画板 | 简单图可由主 Agent 直接写入;复杂图再隔离到 SubAgent |
|
||||
| `lark-doc` | 识别画板机会、使用 Mermaid/SVG 创建图表、调度 SubAgent、插入简单 SVG 画板或复杂空白画板 | 主 Agent 不直接创作画板内容; |
|
||||
| `lark-whiteboard` | 查询/导出已有画板;复杂图表生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入已有/空白画板 | 仅特别复杂的图表或已有画板更新时由独立 SubAgent 读取 |
|
||||
|
||||
## 画板适用规则
|
||||
@@ -29,9 +29,11 @@
|
||||
> [!IMPORTANT]
|
||||
> ⚠️ **分别对每个图表进行决策**
|
||||
|
||||
如果有多个位置需要插入图表,你需要根据每个图表的内容**分别决定**采用步骤 2A 还是 2B。思维导图、时序图、类图、饼图、甘特图可插入 mermaid 块;其他类型图表使用 SVG,简单图由主 Agent 直接写入,复杂图再启动 SubAgent。
|
||||
如果有多个位置需要插入图表,你需要根据每个图表的内容**分别决定**采用步骤 2A 还是 2B
|
||||
中的方式插入这个图表。在需要插入思维导图、时序图、类图、饼图、甘特图的时候可以插入 mermaid 块,在需要插入其他类型图表时启动
|
||||
SubAgent 插入 SVG。
|
||||
|
||||
简单 Mermaid / SVG 图可由主 Agent 直接写入本地 XML;需要专门视觉设计、信息密度较高或容易布局翻车的 SVG,再启动 SubAgent 产出完整片段。
|
||||
建议优先使用 SVG 插入图表,除非其属于思维导图、时序图、类图、饼图、甘特图这类可以直接使用 mermaid 语法描述,且不适宜用 SVG 绘制的图表
|
||||
|
||||
### 步骤 2A: 使用 mermaid 插入图表
|
||||
|
||||
|
||||
93
skills/lark-doc/references/lark-doc-word-stat.md
Normal file
93
skills/lark-doc/references/lark-doc-word-stat.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# 文档统计:总字数 / 总字符数
|
||||
|
||||
当用户需要统计 Docx / Wiki 文档的总字数或总字符数时,使用本 skill 附带脚本 `scripts/doc_word_stat.py`。统计口径以该脚本为准,不要改用其他方式自行计算,也不要只读取 simple 摘要后统计。
|
||||
|
||||
## 调用方式
|
||||
|
||||
在线文档使用 XML full 内容,并让脚本读取 `docs +fetch --format json` 的 envelope:
|
||||
|
||||
```bash
|
||||
lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
| python3 skills/lark-doc/scripts/doc_word_stat.py --protocol xml --lark-json --pretty
|
||||
```
|
||||
|
||||
`$URL` 可以是用户给出的 docx/wiki URL,也可以是可被 `docs +fetch` 解析的 token。
|
||||
|
||||
## 统计范围
|
||||
|
||||
先判断用户要求的是**整篇文档**还是**局部内容**:
|
||||
|
||||
- 整篇文档的总字数 / 总字符数:按上方「调用方式」抓取 `full` 内容后统计。
|
||||
- 本次新增 / 替换 / 改写片段的字数:优先统计拟写内容本身;内容已写入文档时,只 fetch 对应 block / range 后统计。不得用整篇文档字数对比局部目标。
|
||||
|
||||
如需在自动化或回归验证中发现未覆盖块类型,追加严格参数:
|
||||
|
||||
```bash
|
||||
lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
| python3 skills/lark-doc/scripts/doc_word_stat.py --protocol xml --lark-json --pretty --fail-on-unsupported --fail-on-unknown
|
||||
```
|
||||
|
||||
## 如何读取结果
|
||||
|
||||
脚本输出 JSON。对用户汇报时默认只读两个核心字段:
|
||||
|
||||
- `word_count`:总字数。按语义单位统计汉字、英文单词/URL/code path、数字、中文标点;普通贴着英文的英文标点不计入,但独立 ASCII 符号、中文之间的 `/` 等以脚本结果为准。
|
||||
- `char_count`:总字符数。统计汉字、英文字母、数字、中英文标点和脚本识别的可见符号;空格不计入。
|
||||
|
||||
其余字段用于排查或解释:
|
||||
|
||||
- `breakdown`:拆分统计来源,例如 `han_chars`、`english_words`、`digits`、`chinese_punctuations`。
|
||||
- `unknown_blocks`:脚本遇到未知 XML/Markdown 块类型;通常表示需要扩展解析规则。
|
||||
- `unsupported_blocks`:脚本识别到块类型,但当前无法可靠提取可见文本。
|
||||
- `diagnostics.has_unknown` / `diagnostics.has_unsupported`:快速判断统计是否存在覆盖风险。
|
||||
|
||||
如果 `unknown_blocks` 或 `unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。
|
||||
|
||||
## 字数遵循校验
|
||||
|
||||
当用户给了明确字数要求(写 N 字 / x-y 字 / x 字左右 / 上下浮动)时执行;没有明确字数要求则跳过。字数必须按本文流程用脚本统计,不要自己估。
|
||||
|
||||
1. 先按「统计范围」确认统计对象,再把要求归一成目标区间:`>x`→`[x+1, +∞)`;`<y`→`(-∞, y-1]`;`x-y`→`[x, y]`;`x 字左右`→`[round(0.9x), round(1.1x)]`
|
||||
2. 按统计对象选择对应输入并调用脚本统计实际字数,读取输出里的 `word_count`
|
||||
3. 对比 `word_count` 与目标区间:区间内即通过;低于下限 → 补充**实质内容**(非注水);高于上限 → 删减冗余内容。改完重新统计
|
||||
4. **最多 2 轮**。2 轮后仍不达标:停止,不得为达标而注水或删关键内容;如实汇报【目标区间 / 当前字数 / 差值与方向 / 已试 2 轮 / 未达原因】,**禁止谎称达标**
|
||||
|
||||
## 输出示例
|
||||
|
||||
输入正文等价于:`标题` + `一个苹果是 an apple。` 时,输出形态如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"word_count": 10,
|
||||
"char_count": 15,
|
||||
"breakdown": {
|
||||
"han_chars": 7,
|
||||
"english_words": 2,
|
||||
"number_words": 0,
|
||||
"chinese_punctuations": 1,
|
||||
"english_letters": 7,
|
||||
"digits": 0,
|
||||
"english_punctuations": 0,
|
||||
"symbol_words": 0,
|
||||
"symbol_chars": 0
|
||||
},
|
||||
"protocol": "xml",
|
||||
"unknown_blocks": [],
|
||||
"unsupported_blocks": [],
|
||||
"diagnostics": {
|
||||
"has_unknown": false,
|
||||
"has_unsupported": false,
|
||||
"types": {},
|
||||
"unknown_types": {},
|
||||
"unsupported_types": {},
|
||||
"actions": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
面向用户的回复可简化为:
|
||||
|
||||
```text
|
||||
总字数:10
|
||||
总字符数:15
|
||||
```
|
||||
@@ -2,17 +2,6 @@
|
||||
|
||||
本文件用于补充说明 block XML 扩展能力。常用标签和通用规则见 [`lark-doc-xml.md`](lark-doc-xml.md);后续新增其他 block 说明时可继续追加到本文件。
|
||||
|
||||
## 拓展标签
|
||||
- `<figure view-type>视图容器</figure>`
|
||||
- `<bookmark name href="网络链接"></bookmark>`
|
||||
- `<button action=OpenLink|DuplicatePage|FollowPage>操作按钮;可选 background-color、src</button>`
|
||||
- `<time expire-time notify-time should-notify=bool>提醒;使用毫秒时间戳。</time>`
|
||||
- `<sheet type=blank>创建空白表格</sheet>`、`<sheet sheet-id token>复制已有表格。</sheet>`
|
||||
- `<task task-id>挂载任务;task-id 为任务 GUID。</task>`
|
||||
- `<chat_card chat-id>挂载聊天卡片。</chat_card>`
|
||||
- `<sub-page-list>子页面列表块;仅 wiki 文档可插入。</sub-page-list>`
|
||||
- `<okr cycle-id>挂载已有 OKR。</okr>`
|
||||
|
||||
## HTML5 block
|
||||
|
||||
1. 写入 HTML 内容块时,把完整单文件 HTML 存为本地 `.html` 文件,XML 写 `<html5-block path="@widget.html"></html5-block>`;已有 `data-ref` 时配合 `--reference-map @reference-map.json`。读取时 `<html5-block data-ref="html5_1"></html5-block>` 只是占位,必须从 `document.reference_map["html5-block"]["html5_1"].data` 读取 HTML;若 entry 是 `path`,读取对应 `@doc-fetch-resources/...html` 文件。
|
||||
@@ -55,7 +44,6 @@
|
||||
- HTML 总长度上限为 500KB。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
|
||||
|
||||
## OKR block
|
||||
`<okr cycle-id>挂载已有 OKR。</okr>`:创建时仅支持 root-only
|
||||
|
||||
OKR block 可用 XML 格式完整表达。创建前先参考 [`lark-okr`](../../lark-okr/SKILL.md) 确认可用周期;创建时只写 root-only `<okr cycle-id="..."/>` 挂载已有 OKR,不构造 Objective/KR/Progress 子树。
|
||||
|
||||
|
||||
@@ -1,45 +1,183 @@
|
||||
# 飞书 XML 语法
|
||||
基于 HTML 子集的 XML 格式描述飞书文档内容。
|
||||
|
||||
**语法遵循 HTML,渲染遵循 Markdown-Enhanced**。
|
||||
# 一、标准 HTML 标签
|
||||
p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr, img, b, em, u, del, a, br, span 语义不变
|
||||
|
||||
以下为自解释的标签签名:必填属性写在开始标签内,可选属性写在说明中;`bool`=`true|false`,`A|B`=任选一,`T[]`=英文逗号分隔的多值。签名不是可直接复制的 XML;实际输出须为属性值加引号并填写真实值。
|
||||
# 二、扩展标签速查表
|
||||
## 块级标签
|
||||
|标签|说明|关键属性|
|
||||
|-|-|-|
|
||||
| `<title>` | 文档标题(每篇唯一)| `align` |
|
||||
| `<checkbox>` | 待办项| `done="true"\|"false"` |
|
||||
|
||||
## Markdown 常用映射标签
|
||||
## 容器标签
|
||||
|标签|说明|关键属性|
|
||||
|-|-|-|
|
||||
| `<callout>` | 高亮框,子块仅支持文本、标题、列表、待办、引用 | `emoji`(默认 bulb), `background-color`, `border-color`, `text-color` |
|
||||
| `<grid>` + `<column>` | 分栏布局,各列 width-ratio 之和为 1 | `width-ratio` |
|
||||
| `<whiteboard>` | 嵌入画板 | `type`: `blank` \| `mermaid` \| `plantuml` \| `svg` |
|
||||
| `<pre>` | (代码块,内含 `code`)| `lang`, `caption` |
|
||||
| `<figure>` | 视图容器 | `view-type` |
|
||||
| `<bookmark>` | 书签链接 | `<bookmark name="标题" href="https://..."></bookmark>`,必传 name 和 href |
|
||||
|
||||
- `p, h1-h6, blockquote, hr, img, b, em, u, del, br, span` 语义不变。
|
||||
- `<a type=url-preview href>链接标题;渲染为预览卡片。</a>`
|
||||
- `<latex>行内公式,如 E = mc^2。</latex>`
|
||||
- `<ol><li seq="1">order1:seq=1 表示序号从1开始,为空时表示继承前序<ul><li>item1:子列表放在 li 内;新增列表项必须包在 ul 或 ol;</li><li>item2</li></ul></li><li>order1</li></ol>`
|
||||
- `<table><colgroup><col/><col/></colgroup><thead><tr><th><p></p></th><th><p></p></th></tr></thead><tbody><tr><td><p></p></td><td><p></p></td></tr></tbody></table>`:表格。
|
||||
- `<pre lang="类型"><code>代码内容</code></pre>`:代码块;可选 `caption`;代码必须放在 `<code>` 内,禁止直接放在 `<pre>` 下。
|
||||
- `<img/>`:href="上传网络图片,支持 HTTP(S)";src="token,复制原始图片";href 和 src 必须存在一个;可选 `width, height, caption, name`。
|
||||
- `<source name/>`:文件附件,可独立成块或内联。
|
||||
- `<checkbox done=bool>待办项</checkbox>`
|
||||
- `p, h1-h9, li, checkbox, title` 可选属性 `align=left|center|right`。
|
||||
## 行内组件
|
||||
| 标签 | 说明 | 关键属性 |
|
||||
|-|-|-|
|
||||
| `<cite type="user">` | @人 | XML 导入时必须显式传入 `user-id`:`<cite type="user" user-id="userID"></cite>` |
|
||||
| `<cite type="doc">` | @文档 | `<cite type="doc" doc-id="docx_token"></cite>` |
|
||||
| `<latex>` | 行内公式 | `<latex>E = mc^2</latex>` |
|
||||
| `<img>` | 图片(可独立成块或内联) | `<img width="800" height="600" caption="说明" name="图.png" href="http 或 https"/>` |
|
||||
| `<source>` | 文件附件(可独立成块或内联) | `<source name="报告.pdf"/>` |
|
||||
| `<a type="url-preview">` | 预览卡片 | `<a type="url-preview" href="...">标题</a>` |
|
||||
| `<button>` | 操作按钮 | `background-color`、`src`,必须包含 `action=OpenLink\|DuplicatePage\|FollowPage` |
|
||||
| `<time>` | 提醒 | 必包含 `expire-time`、`notify-time`(毫秒时间戳)、`should-notify=true\|false` |
|
||||
|
||||
## 必备标签
|
||||
## 文本块通用属性
|
||||
- `align` — `"left"`|`"center"`|`"right"`(适用于 p / h1-h9 / li / checkbox)
|
||||
- 有序列表项用 `seq="auto"` 自动编号
|
||||
|
||||
- `<title>必有文档标题,每篇唯一</title>`
|
||||
# 三、资源块
|
||||
|
||||
## 飞书特有拓展标签
|
||||
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
|
||||
|
||||
- `<cite type=user user-id="open-id"></cite>`:@人,会渲染为用户头像,不得写纯文本名字,必须显式传入 `user-id`
|
||||
- `<cite type=doc doc-id="doc-token"></cite>`:@文档,会渲染为文档标题
|
||||
- `<whiteboard type=blank|mermaid|plantuml|svg path="@相对路径文件">支持通过 path 直接导入,也支持直接写入。</whiteboard>`
|
||||
- `<grid><column width-ratio=0.5><p>分栏;各列 width-ratio 之和必须为 1。</p></column><column width-ratio=0.5><p>内容</p></column></grid>`
|
||||
- `<callout><p>高亮块内容,无特殊渲染要求、正式场景慎用;子块仅支持文本、标题、列表、待办、引用;可选 emoji(默认 bulb)、background-color、border-color、text-color。</p></callout>`
|
||||
- 其他拓展标签时,figure、bookmark、button、time、sheet、task、chat_card、sub-page-list、okr, 可查看 [`lark-doc-xml-extended-blocks.md`](lark-doc-xml-extended-blocks.md#okr-block)。
|
||||
- `<img>` — `<img href="https://..."/>` 上传网络图片
|
||||
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;也可用本地文件简写 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`、`<whiteboard type="mermaid" path="@flow.mmd"></whiteboard>`、`<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`,CLI 会写入前展开为内联内容;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
|
||||
- `<sheet>` — `<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
|
||||
- `<task>` — `<task task-id="GUID"></task>`,必传 task-id(任务 guid)
|
||||
- `<chat_card>` — `<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
|
||||
- `<sub-page-list>` — `<sub-page-list></sub-page-list>` 子页面列表块;仅 wiki 文档可插入
|
||||
- `<html5-block>`、`<okr>` — 前者在飞书文档「HTML 块」iframe 中加载单文件 HTML,内容可用 HTML 渲染时直接使用;后者创建时仅支持 root-only `<okr cycle-id="..."/>` 挂载已有 OKR。完整语法与字段规则见 [`lark-doc-xml-extended-blocks.md`](lark-doc-xml-extended-blocks.md)。
|
||||
- bitable、base_ref、synced_reference、synced_source — 不可创建,仅支持移动
|
||||
|
||||
## 颜色与美化
|
||||
- 基础色:`red, orange, yellow, green, blue, purple, gray`;常用 emoji:💡(默认)、✅、❌、📝、❓、❗、👍、❤️、📌、🏁、⭐。
|
||||
- `<span text-color>`、`<callout text-color>`、`<callout border-color>`:基础色。
|
||||
- `<span background-color>`、`<th/td background-color>`、`<button background-color>`:基础色 + `light-{色}` + `medium-gray`。
|
||||
- `<callout background-color>`:`gray` + `light-{色}` + `medium-{色}`。
|
||||
# 四、块级复制与移动
|
||||
|
||||
## 转义规则
|
||||
## 移动(block_move_after)
|
||||
支持**所有**块类型(块级标签、容器标签、行内组件、资源块),使用 `docs +update --command block_move_after --block-id "<锚点>" --src-block-ids "id1,id2"`。
|
||||
|
||||
禁止转义标签本身;只转义标签内部的文本内容。
|
||||
## 复制(block_copy_insert_after)
|
||||
- **基础标签**(块级标签、容器标签、行内组件):均支持复制
|
||||
- **资源块**:仅 img、source、whiteboard、sheet、chat_card、sub-page-list 支持复制;task、bitable、base_ref、synced_reference、synced_source、okr 不支持复制
|
||||
|
||||
- 文本转义:`<` → `<`,`>` → `>`,`&` → `&`,换行符 `\n` → `<br/>`。
|
||||
- 错误:`<p>内容</p>`
|
||||
- 正确:`<p>A & B 的对比:1 < 2</p>`
|
||||
使用 `docs +update --command block_copy_insert_after --block-id "<锚点>" --src-block-ids "id1,id2"`。
|
||||
|
||||
> 详见 [lark-doc-update.md](lark-doc-update.md)。
|
||||
|
||||
# 五、补充规则
|
||||
|
||||
## 富文本样式嵌套顺序
|
||||
- 行内样式标签必须按以下固定顺序嵌套(外 → 内),关闭顺序严格反转:`<a> → <b> → <em> → <del> → <u> → <code> → <span> → 文本内容`
|
||||
|
||||
## 列表分组
|
||||
- 连续同类型列表项自动合并为一个 `<ul>` 或 `<ol>`
|
||||
- 嵌套子列表放在 `<li>` 内部
|
||||
- 新增列表项必须包在 `<ul>` 或 `<ol>` 内:
|
||||
```xml
|
||||
<ul>
|
||||
<li>第一项</li>
|
||||
<li>第二项</li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
## 代码块
|
||||
- 代码块必须写成 `<pre lang="xxx" caption="可选说明"><code>代码内容</code></pre>`。
|
||||
- 不要将代码文本直接放在 `<pre>` 下;应放在内层 `<code>` 中。
|
||||
|
||||
|
||||
## 用户名写入规则
|
||||
|
||||
- 任何包含 `<cite type="user">` 的 XML 在导入、新建或编辑回写时,都必须显式传入 `user-id`;其值为用户的 `open_id`,不得省略。
|
||||
- 当从 IM 消息、日历、审批、任务等来源获取到用户的 `open_id` 时,写入文档**必须**使用 `<cite type="user" user-id="open_id">` 标签,而非纯文本名字。这样文档中会渲染为可点击的 @人。
|
||||
- 典型场景:IM 消息的 `sender`、`mentions`、reactions 的 `operator`、卡片消息中引用的用户、系统消息中的用户名、合并转发中的用户名。
|
||||
- 当只有纯文本名字而没有 `open_id` 时(如系统消息、合并转发内容),先通过 `lark-cli contact +search-user --query "名字" --as user` 反查 `open_id`,再写入 cite 标签。
|
||||
|
||||
## 表格扩展
|
||||
标准 HTML table 结构不变,扩展点:
|
||||
- `<colgroup>` / `<col>` 定义列宽,紧跟 `<table>` 之后:`<col span="2" width="100"/>`
|
||||
- `<th>` / `<td>` 增加 `background-color` 和 `vertical-align`(top | middle | bottom)
|
||||
- 有表头时第一行在 `<thead>` 用 `<th>`,其余在 `<tbody>` 用 `<td>`
|
||||
- 合并单元格仅起始格输出 `colspan` / `rowspan`,被合并的格不出现
|
||||
|
||||
# 六、美化系统
|
||||
- 颜色优先使用命名色,也可写 `rgb(r,g,b)` / `rgba(r,g,b,a)`。**基础色(7 色)**:red, orange, yellow, green, blue, purple, gray
|
||||
| 属性 | 支持的命名色 |
|
||||
|-|-|
|
||||
| 文字颜色 `<span text-color>` | 基础色 |
|
||||
| 高亮框字色 `<callout text-color>` | 基础色 |
|
||||
| 高亮框边框 `<callout border-color>` | 基础色 |
|
||||
| 文字背景 `<span background-color>` | 基础色 + `light-{色}` + `medium-gray` |
|
||||
| 高亮框填充 `<callout background-color>` | `gray` + `light-{色}` + `medium-{色}` |
|
||||
| 单元格背景 `<th/td background-color>` | 同文字背景 |
|
||||
| 按钮背景 `<button background-color>` | 同文字背景 |
|
||||
- 常用 emoji: 💡(默认)✅❌📝❓❗👍❤️📌🏁⭐
|
||||
|
||||
# 七、**重要规则**
|
||||
## 转义规则:标签本身 **禁止转义**,只有标签内部的文本内容才需要转义
|
||||
|
||||
**错误** ❌:`<p>内容</p>`(把标签也转义了)
|
||||
**正确** ✅:`<p>A & B 的对比:1 < 2</p>`(标签保持原样,文本中的 `&` 和 `<` 才转义)
|
||||
|
||||
转义字符表:
|
||||
- `<` → `<`
|
||||
- `>` → `>`
|
||||
- `&` → `&`
|
||||
- `\n`(换行符) → `<br/>`
|
||||
|
||||
|
||||
# 八、完整示例
|
||||
|
||||
```xml
|
||||
<title>文档标题</title>
|
||||
|
||||
<h1>一级标题</h1>
|
||||
|
||||
<p><b>加粗文本</b>,<span text-color="green">绿色文本</span></p>
|
||||
|
||||
<callout emoji="💡" background-color="light-yellow" border-color="yellow">
|
||||
<p>高亮框内容,子块仅支持文本/标题/列表/待办/引用</p>
|
||||
</callout>
|
||||
|
||||
<checkbox done="true">已完成事项</checkbox>
|
||||
<checkbox done="false">未完成事项</checkbox>
|
||||
|
||||
<grid>
|
||||
<column width-ratio="0.5">
|
||||
<p>左栏</p>
|
||||
</column>
|
||||
<column width-ratio="0.5">
|
||||
<p>右栏</p>
|
||||
</column>
|
||||
</grid>
|
||||
|
||||
<table>
|
||||
<colgroup><col span="2" width="120"/></colgroup>
|
||||
<thead><tr><th background-color="light-gray">表头</th><th background-color="light-gray">表头</th></tr></thead>
|
||||
<tbody><tr><td>单元格</td><td>单元格</td></tr></tbody>
|
||||
</table>
|
||||
|
||||
<p><cite type="doc" doc-id="DOC_TOKEN"></cite> <cite type="user" user-id="USER_ID"></cite></p>
|
||||
|
||||
<ol><li seq="auto">第一项</li><li seq="auto">第二项</li></ol>
|
||||
|
||||
<p><a type="url-preview" href="https://example.com">链接标题</a></p>
|
||||
|
||||
<p><latex>E = mc^2</latex></p>
|
||||
|
||||
<pre lang="go" caption="示例"><code>fmt.Println("hello")</code></pre>
|
||||
|
||||
<hr/>
|
||||
|
||||
<source name="文件名.pdf"/>
|
||||
<img src="IMG_TOKEN" width="800" height="400" caption="说明" name="图.png"/>
|
||||
<img href="https://example.com/photo.png"/>
|
||||
|
||||
<button action="OpenLink" src="https://example.com">按钮文字</button>
|
||||
|
||||
<time expire-time="1775916000000" notify-time="1775912400000" should-notify="false">时间戳毫秒</time>
|
||||
|
||||
<cite type="citation"><a href="https://example.com">引文标题</a></cite>
|
||||
<bookmark name="书签标题" href="https://example.com"></bookmark>
|
||||
|
||||
<task task-id="TASK_GUID"></task>
|
||||
<chat_card chat-id="CHAT_ID"></chat_card>
|
||||
<sub-page-list></sub-page-list>
|
||||
```
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user