diff --git a/cmd/root_test.go b/cmd/root_test.go index 096c404de0..21836ea66d 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -624,7 +624,7 @@ func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testi t.Errorf("Subtype = %q, want %q", problem.Subtype, errs.SubtypeUnknown) } - if !strings.Contains(problem.Hint, `auth login --scope "docx:document:create" --no-wait --json`) { + if !strings.Contains(problem.Hint, `auth login --scope "docx:document:create docs:document.media:upload docx:document:write_only docx:document:readonly" --no-wait --json`) { t.Errorf("expected shortcut scoped recovery, got %q", problem.Hint) } } @@ -699,7 +699,12 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) { t.Errorf("rendered error lost need-authorization cause %v: %v", authErr.Cause, rendered) } - want := "existing hint\n" + recovery.UserAuthorization("docx:document:create").String() + want := "existing hint\n" + recovery.UserAuthorization( + "docx:document:create", + "docs:document.media:upload", + "docx:document:write_only", + "docx:document:readonly", + ).String() if problem.Hint != want { t.Errorf("expected appended hint %q, got %q", want, problem.Hint) } diff --git a/extension/fileio/types.go b/extension/fileio/types.go index 386bda198e..b5ae9f12ce 100644 --- a/extension/fileio/types.go +++ b/extension/fileio/types.go @@ -42,6 +42,15 @@ type FileIO interface { Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error) } +// WorkspaceFileIO is an optional extension for commands that own temporary +// workspace entries. RemoveWorkspaceEntry must remove exactly one file or one +// empty directory, never recursively, and must apply the same path validation +// and storage backend as the embedded FileIO methods. +type WorkspaceFileIO interface { + FileIO + RemoveWorkspaceEntry(path string) error +} + // FileInfo is a minimal subset of os.FileInfo covering actual CLI usage. // os.FileInfo satisfies this interface. type FileInfo interface { diff --git a/go.mod b/go.mod index 26497138b2..8839b7fc71 100644 --- a/go.mod +++ b/go.mod @@ -19,11 +19,12 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 github.com/zalando/go-keyring v0.2.8 + golang.org/x/image v0.30.0 golang.org/x/net v0.33.0 - golang.org/x/sync v0.15.0 + golang.org/x/sync v0.16.0 golang.org/x/sys v0.33.0 golang.org/x/term v0.27.0 - golang.org/x/text v0.23.0 + golang.org/x/text v0.28.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 7e42f36199..574157832b 100644 --- a/go.sum +++ b/go.sum @@ -139,6 +139,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= +golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -150,8 +152,8 @@ golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -163,8 +165,8 @@ golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/internal/vfs/localfileio/localfileio.go b/internal/vfs/localfileio/localfileio.go index 9fd60bc1ca..9712b9e744 100644 --- a/internal/vfs/localfileio/localfileio.go +++ b/internal/vfs/localfileio/localfileio.go @@ -30,6 +30,8 @@ func init() { // and atomic writes are handled internally. type LocalFileIO struct{} +var _ fileio.WorkspaceFileIO = (*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) @@ -79,3 +81,13 @@ func (l *LocalFileIO) Save(path string, _ fileio.SaveOptions, body io.Reader) (f } return &saveResult{size: n}, nil } + +// RemoveWorkspaceEntry removes one workspace file or one empty workspace +// directory after applying the same output-path validation as Save. +func (l *LocalFileIO) RemoveWorkspaceEntry(path string) error { + safePath, err := SafeOutputPath(path) + if err != nil { + return &fileio.PathValidationError{Err: err} + } + return vfs.Remove(safePath) +} diff --git a/internal/vfs/localfileio/localfileio_test.go b/internal/vfs/localfileio/localfileio_test.go index 9581165ac1..1783afb6c2 100644 --- a/internal/vfs/localfileio/localfileio_test.go +++ b/internal/vfs/localfileio/localfileio_test.go @@ -4,6 +4,7 @@ package localfileio import ( + "errors" "io" "os" "path/filepath" @@ -212,6 +213,44 @@ func TestLocalFileIO_Save_RejectsAbsolutePath(t *testing.T) { } } +func TestLocalFileIO_RemoveWorkspaceEntry_IsValidatedAndNonRecursive(t *testing.T) { + dir := t.TempDir() + testChdir(t, dir) + + fio := &LocalFileIO{} + workspace := "draft_workspace" + decisionPath := filepath.Join(workspace, ".presentation-decision.json") + keepPath := filepath.Join(workspace, "keep.txt") + if _, err := fio.Save(decisionPath, fileio.SaveOptions{}, strings.NewReader("{}")); err != nil { + t.Fatalf("Save decision: %v", err) + } + if _, err := fio.Save(keepPath, fileio.SaveOptions{}, strings.NewReader("keep")); err != nil { + t.Fatalf("Save retained entry: %v", err) + } + + if err := fio.RemoveWorkspaceEntry(decisionPath); err != nil { + t.Fatalf("RemoveWorkspaceEntry file: %v", err) + } + if _, err := os.Stat(decisionPath); !os.IsNotExist(err) { + t.Fatalf("removed decision still exists or stat failed unexpectedly: %v", err) + } + if err := fio.RemoveWorkspaceEntry(workspace); err == nil { + t.Fatal("RemoveWorkspaceEntry recursively removed a non-empty directory") + } + if _, err := os.Stat(keepPath); err != nil { + t.Fatalf("non-recursive removal deleted retained entry: %v", err) + } + if err := fio.RemoveWorkspaceEntry(keepPath); err != nil { + t.Fatalf("RemoveWorkspaceEntry retained file: %v", err) + } + if err := fio.RemoveWorkspaceEntry(workspace); err != nil { + t.Fatalf("RemoveWorkspaceEntry empty directory: %v", err) + } + if err := fio.RemoveWorkspaceEntry("../outside"); !errors.Is(err, fileio.ErrPathValidation) { + t.Fatalf("traversal error = %v, want fileio.ErrPathValidation", err) + } +} + // ── ResolvePath ── func TestLocalFileIO_ResolvePath_ReturnsAbsolute(t *testing.T) { diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index e12224f0cd..e21ae8337d 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -711,6 +711,13 @@ func (ctx *RuntimeContext) handleEmitterError(err error) { ctx.outputErrOnce.Do(func() { ctx.outputErr = err }) } +// OutputError returns the first deferred output failure captured by Out, +// OutRaw, or OutFormat. Commands that create local artifacts can use it to +// roll those artifacts back before returning the final command error. +func (ctx *RuntimeContext) OutputError() error { + return ctx.outputErr +} + func wrapLegacyPrettyRenderer(prettyFn func(w io.Writer)) output.PrettyRenderer { if prettyFn == nil { return nil diff --git a/shortcuts/doc/doc_media_ext.go b/shortcuts/doc/doc_media_ext.go index 6ce96a10c3..17f0a3f67b 100644 --- a/shortcuts/doc/doc_media_ext.go +++ b/shortcuts/doc/doc_media_ext.go @@ -33,6 +33,7 @@ var docMediaMimeToExt = map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/svg+xml": ".svg", + "image/tiff": ".tiff", "image/webp": ".webp", "text/csv": ".csv", "text/html": ".html", diff --git a/shortcuts/doc/doc_media_insert.go b/shortcuts/doc/doc_media_insert.go index 495f5067cb..084eaf3650 100644 --- a/shortcuts/doc/doc_media_insert.go +++ b/shortcuts/doc/doc_media_insert.go @@ -15,6 +15,10 @@ import ( "path/filepath" "strings" + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/validate" @@ -531,7 +535,7 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string case "docx": return docRef.Token, nil case "doc": - return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "this document operation only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc") case "wiki": fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token)) data, err := runtime.CallAPITyped( @@ -551,13 +555,13 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data") } if objType != "docx" { - return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but docs +media-insert only supports docx documents", objType).WithParam("--doc") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but this document operation only supports docx documents", objType).WithParam("--doc") } fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to docx: %s\n", common.MaskToken(objToken)) return objToken, nil default: - return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents").WithParam("--doc") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "this document operation only supports docx documents").WithParam("--doc") } } @@ -594,15 +598,24 @@ func detectImageDimensions(r io.Reader) (width, height int, err error) { } func detectImageDimensionsFromPath(fio fileio.FileIO, filePath string) (int, int, error) { + width, height, _, err := detectImageConfigFromPath(fio, filePath) + return width, height, err +} + +func detectImageConfigFromPath(fio fileio.FileIO, filePath string) (int, int, string, error) { if _, err := validate.SafeInputPath(filePath); err != nil { - return 0, 0, err + return 0, 0, "", err } f, err := fio.Open(filePath) if err != nil { - return 0, 0, err + return 0, 0, "", err } defer f.Close() - return detectImageDimensions(f) + cfg, format, err := image.DecodeConfig(f) + if err != nil { + return 0, 0, "", err + } + return cfg.Width, cfg.Height, format, nil } func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption string, width, height int) map[string]interface{} { diff --git a/shortcuts/doc/doc_media_insert_test.go b/shortcuts/doc/doc_media_insert_test.go index 19574423be..9835fb486d 100644 --- a/shortcuts/doc/doc_media_insert_test.go +++ b/shortcuts/doc/doc_media_insert_test.go @@ -4,7 +4,9 @@ package doc import ( + "bytes" "context" + "encoding/binary" "encoding/json" "fmt" "reflect" @@ -18,6 +20,100 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +func TestDetectImageDimensionsSupportsBMPAndTIFF(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + image []byte + width int + height int + }{ + {name: "BMP", image: testBMPImage(2, 3), width: 2, height: 3}, + {name: "TIFF", image: testTIFFImage(4, 5), width: 4, height: 5}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + width, height, err := detectImageDimensions(bytes.NewReader(tt.image)) + if err != nil { + t.Fatalf("detectImageDimensions() error: %v", err) + } + if width != tt.width || height != tt.height { + t.Fatalf("dimensions = %dx%d, want %dx%d", width, height, tt.width, tt.height) + } + }) + } +} + +func TestDocumentImageMIMETypesAndExtensions(t *testing.T) { + t.Parallel() + + tests := []struct { + contentType string + ext string + }{ + {contentType: "image/bmp", ext: ".bmp"}, + {contentType: "image/gif", ext: ".gif"}, + {contentType: "image/jpeg", ext: ".jpg"}, + {contentType: "image/png", ext: ".png"}, + {contentType: "image/tiff", ext: ".tiff"}, + {contentType: "image/webp", ext: ".webp"}, + } + for _, tt := range tests { + t.Run(tt.contentType, func(t *testing.T) { + if got := docCoverAllowedContentTypes[tt.contentType]; got != tt.ext { + t.Fatalf("allowed extension = %q, want %q", got, tt.ext) + } + resolution := docMediaExtensionByContentType(tt.contentType + "; charset=binary") + if resolution == nil || resolution.Ext != tt.ext { + t.Fatalf("extension resolution = %#v, want %q", resolution, tt.ext) + } + }) + } +} + +func testBMPImage(width, height int) []byte { + rowSize := (width*3 + 3) &^ 3 + pixelBytes := rowSize * height + data := make([]byte, 54+pixelBytes) + copy(data[:2], "BM") + binary.LittleEndian.PutUint32(data[2:6], uint32(len(data))) + binary.LittleEndian.PutUint32(data[10:14], 54) + binary.LittleEndian.PutUint32(data[14:18], 40) + binary.LittleEndian.PutUint32(data[18:22], uint32(width)) + binary.LittleEndian.PutUint32(data[22:26], uint32(height)) + binary.LittleEndian.PutUint16(data[26:28], 1) + binary.LittleEndian.PutUint16(data[28:30], 24) + binary.LittleEndian.PutUint32(data[34:38], uint32(pixelBytes)) + return data +} + +func testTIFFImage(width, height int) []byte { + const entryCount = 4 + data := make([]byte, 8+2+entryCount*12) + copy(data[:4], []byte{'I', 'I', 42, 0}) + binary.LittleEndian.PutUint32(data[4:8], 8) + binary.LittleEndian.PutUint16(data[8:10], entryCount) + + entries := []struct { + tag, fieldType uint16 + value uint32 + }{ + {tag: 256, fieldType: 4, value: uint32(width)}, // ImageWidth, LONG + {tag: 257, fieldType: 4, value: uint32(height)}, // ImageLength, LONG + {tag: 258, fieldType: 3, value: 8}, // BitsPerSample, SHORT + {tag: 262, fieldType: 3, value: 1}, // PhotometricInterpretation, SHORT + } + for i, entry := range entries { + offset := 10 + i*12 + binary.LittleEndian.PutUint16(data[offset:offset+2], entry.tag) + binary.LittleEndian.PutUint16(data[offset+2:offset+4], entry.fieldType) + binary.LittleEndian.PutUint32(data[offset+4:offset+8], 1) + binary.LittleEndian.PutUint32(data[offset+8:offset+12], entry.value) + } + return data +} + func TestBuildCreateBlockDataUsesConcreteAppendIndex(t *testing.T) { t.Parallel() diff --git a/shortcuts/doc/doc_resource_cover.go b/shortcuts/doc/doc_resource_cover.go index 940e752246..d322d9655a 100644 --- a/shortcuts/doc/doc_resource_cover.go +++ b/shortcuts/doc/doc_resource_cover.go @@ -41,9 +41,11 @@ func (c docCoverHTTPStatusCause) Error() string { } var docCoverAllowedContentTypes = map[string]string{ + "image/bmp": ".bmp", "image/gif": ".gif", "image/jpeg": ".jpg", "image/png": ".png", + "image/tiff": ".tiff", "image/webp": ".webp", } @@ -566,7 +568,7 @@ func downloadDocCoverURL(ctx context.Context, runtime *common.RuntimeContext, ra mediaType = strings.ToLower(mediaType) ext, ok := docCoverAllowedContentTypes[mediaType] if !ok { - return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL Content-Type %q is not supported; expected image/png, image/jpeg, image/gif or image/webp", mediaType).WithParam("--url") + return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL Content-Type %q is not supported; expected image/bmp, image/gif, image/jpeg, image/png, image/tiff, or image/webp", mediaType).WithParam("--url") } limited := io.LimitReader(resp.Body, docCoverURLMaxBytes+1) diff --git a/shortcuts/doc/docs_create.go b/shortcuts/doc/docs_create.go index d035a77075..04788fce4f 100644 --- a/shortcuts/doc/docs_create.go +++ b/shortcuts/doc/docs_create.go @@ -5,22 +5,35 @@ package doc import ( "context" + "strings" + + "github.com/spf13/cobra" "github.com/larksuite/cli/shortcuts/common" ) +const docsContentPathAnnotation = "lark-cli.docs.content-input-path" + // v1CreateFlags returns hidden parse-only compatibility flags for old v1 commands. func v1CreateFlags() []common.Flag { return docsLegacyFlagDefinitions(docsCreateLegacyFlags()) } +var docsCreateLocalResourceScopes = []string{ + "docs:document.media:upload", + "docx:document:write_only", + "docx:document:readonly", +} + var DocsCreate = common.Shortcut{ - Service: "docs", - Command: "+create", - Description: "Create a Lark document", - Risk: "write", - AuthTypes: []string{"user", "bot"}, - Scopes: []string{"docx:document:create"}, + Service: "docs", + Command: "+create", + Description: "Create a Lark document", + Risk: "write", + AuthTypes: []string{"user", "bot"}, + Scopes: []string{"docx:document:create"}, + ConditionalScopes: docsCreateLocalResourceScopes, + PostMount: installDocsContentPathCapture, Flags: concatFlags( []common.Flag{ docsAPIVersionCompatFlag(), @@ -39,6 +52,33 @@ var DocsCreate = common.Shortcut{ }, } +func installDocsContentPathCapture(cmd *cobra.Command) { + previousPreRunE := cmd.PreRunE + cmd.PreRunE = func(command *cobra.Command, args []string) error { + if previousPreRunE != nil { + if err := previousPreRunE(command, args); err != nil { + return err + } + } + captureDocsContentPath(command) + return nil + } +} + +func captureDocsContentPath(cmd *cobra.Command) { + if cmd.Annotations == nil { + cmd.Annotations = make(map[string]string) + } + delete(cmd.Annotations, docsContentPathAnnotation) + raw, err := cmd.Flags().GetString("content") + if err != nil || !strings.HasPrefix(raw, "@") || strings.HasPrefix(raw, "@@") { + return + } + if path := strings.TrimSpace(strings.TrimPrefix(raw, "@")); path != "" { + cmd.Annotations[docsContentPathAnnotation] = path + } +} + // concatFlags combines multiple flag slices into one. func concatFlags(slices ...[]common.Flag) []common.Flag { var out []common.Flag diff --git a/shortcuts/doc/docs_create_test.go b/shortcuts/doc/docs_create_test.go index 1c08fdd63f..f4c5677a73 100644 --- a/shortcuts/doc/docs_create_test.go +++ b/shortcuts/doc/docs_create_test.go @@ -7,6 +7,7 @@ import ( "bytes" "encoding/json" "errors" + "os" "strings" "testing" @@ -19,6 +20,64 @@ import ( // ── V2 (OpenAPI) tests ── +func TestDocsCreateV2RemoteImageDryRunDownloadsAfterDocumentCreation(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--content", `Remote image`, + "--dry-run", + "--as", "bot", + }) + if err != nil { + t.Fatalf("execute docs +create dry-run: %v", err) + } + var envelope struct { + Data struct { + API []common.DryRunAPICall `json:"api"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout) + } + if len(envelope.Data.API) < 3 { + t.Fatalf("dry-run API calls = %d, want create, download, and upload: %#v", len(envelope.Data.API), envelope.Data.API) + } + if got := envelope.Data.API[0].URL; got != "/open-apis/docs_ai/v1/documents" { + t.Fatalf("first API URL = %q, want document creation", got) + } + if got := envelope.Data.API[1].URL; got != "https://93.184.216.34/photo.png" { + t.Fatalf("second API URL = %q, want remote image download", got) + } + if got := envelope.Data.API[2].URL; got != "/open-apis/drive/v1/medias/upload_all" { + t.Fatalf("third API URL = %q, want image upload", got) + } +} + +func TestDocsCreateV2RejectsBlockedRemoteImageBeforeDocumentCreation(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + createStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents", + Optional: true, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + } + reg.Register(createStub) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--content", `Blocked image`, + "--as", "bot", + }) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, "href") + if len(createStub.CapturedBodies) != 0 { + t.Fatalf("document creation was called before remote image validation: %s", createStub.CapturedBody) + } +} + func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) { t.Parallel() @@ -311,6 +370,33 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) { } } +func TestDocsCreateV2EmptyContentFileReportsPathAndRecovery(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + if err := os.WriteFile("draft.xml", nil, 0o600); err != nil { + t.Fatalf("write empty draft: %v", err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--doc-format", "xml", + "--content", "@draft.xml", + "--as", "user", + }) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--content") + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error does not expose a typed problem: %v", err) + } + if got, want := problem.Message, `--content file "draft.xml" is empty`; got != want { + t.Fatalf("message = %q, want %q", got, want) + } + if got, want := problem.Hint, `write non-empty XML or Markdown to this file; if the path was reserved by init-draft, use the exact data.draft_path returned by that command, then retry with --content "@./"`; got != want { + t.Fatalf("hint = %q, want %q", got, want) + } +} + // ── Helpers ── func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig { diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go index 20fff1b6b2..6c476edd1c 100644 --- a/shortcuts/doc/docs_create_v2.go +++ b/shortcuts/doc/docs_create_v2.go @@ -42,18 +42,39 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error { errs.InvalidParam{Name: "--parent-position", Reason: "mutually exclusive with --parent-token"}, ) } - if runtime.Str("content") == "" && title == "" { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content") - } - if runtime.Str("content") != "" { - _, err := resolveDocsV2ContentReferenceMap(runtime) - return err + content := runtime.Str("content") + if strings.TrimSpace(content) == "" && title == "" { + if path, ok := runtime.Cmd.Annotations[docsContentPathAnnotation]; ok { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--content file %q is empty", path). + WithParam("--content"). + WithHint("write non-empty XML or Markdown to this file; if the path was reserved by init-draft, use the exact data.draft_path returned by that command, then retry with --content \"@./\"") + } + if runtime.Changed("content") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--content was provided but is empty; an @file input may point to an empty draft"). + WithParam("--content"). + WithHint("write non-empty XML or Markdown to the draft and retry with --content \"@./\", or omit --content and pass --title to create a title-only document") + } + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--content is required unless --title is provided"). + WithParam("--content"). + WithHint("provide XML or Markdown directly, use --content \"@relative/path\", or pass --title to create a title-only document") + } + if content != "" { + input, err := resolveDocsV2ContentReferenceMap(runtime) + if err != nil { + return err + } + if len(input.LocalResources) > 0 { + return runtime.EnsureScopes(docsCreateLocalResourceScopes) + } } return nil } func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - body, err := buildCreateBodyWithHTML5ReferenceMap(runtime) + body, resources, err := buildCreateBodyWithPreparedInput(runtime) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } @@ -61,25 +82,39 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D if runtime.IsBot() { desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new document." } - return common.NewDryRunAPI(). + dry := common.NewDryRunAPI(). POST("/open-apis/docs_ai/v1/documents"). Desc(desc). Body(body) + dry = appendRemoteDocImageDownloadsDryRun(dry, resources) + return appendLocalDocResourcesDryRun(dry, "", resources) } func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error { - body, err := buildCreateBodyWithHTML5ReferenceMap(runtime) + body, resources, err := buildCreateBodyWithPreparedInput(runtime) if err != nil { return err } + if err := validateRemoteDocImageSources(runtime.Ctx(), resources); err != nil { + return err + } data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body) if err != nil { return err } + if docsAPIOperationFailed(data) { + return runtime.OutPartialFailure(data, nil) + } augmentDocsCreatePermission(runtime, data) fallbackDocsCreateURLV2(runtime, data) + if len(resources) > 0 { + doc, _ := data["document"].(map[string]interface{}) + if err := finalizeLocalDocResources(runtime, strings.TrimSpace(common.GetString(doc, "document_id")), data, resources); err != nil { + return err + } + } runtime.OutRaw(data, nil) return nil } diff --git a/shortcuts/doc/docs_result_test.go b/shortcuts/doc/docs_result_test.go new file mode 100644 index 0000000000..393f34a85d --- /dev/null +++ b/shortcuts/doc/docs_result_test.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" +) + +func TestDocsWritePreservesServiceFailureDetails(t *testing.T) { + tests := []struct { + name string + method string + path string + args []string + }{ + { + name: "create", + method: "POST", + path: "/open-apis/docs_ai/v1/documents", + args: []string{"+create", "--content", "

content

", "--as", "user"}, + }, + { + name: "update", + method: "PUT", + path: "/open-apis/docs_ai/v1/documents/doxcn_failed", + args: []string{"+update", "--doc", "doxcn_failed", "--command", "append", "--content", "

content

", "--as", "user"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-result-"+tt.name)) + registerDocsAIStub(reg, tt.method, tt.path, map[string]interface{}{ + "result": "failed", + "warnings": []interface{}{"the target block is not replaceable"}, + }) + + shortcut := DocsCreate + if tt.name == "update" { + shortcut = DocsUpdate + } + err := mountAndRunDocs(t, shortcut, tt.args, f, stdout) + var partial *output.PartialFailureError + if !errors.As(err, &partial) { + t.Fatalf("error = %T %v, want *output.PartialFailureError", err, err) + } + + var envelope struct { + OK bool `json:"ok"` + Data struct { + Result string `json:"result"` + Warnings []string `json:"warnings"` + } `json:"data"` + } + if decodeErr := json.Unmarshal(stdout.Bytes(), &envelope); decodeErr != nil { + t.Fatalf("decode stdout: %v\n%s", decodeErr, stdout.String()) + } + if envelope.OK || envelope.Data.Result != "failed" || len(envelope.Data.Warnings) != 1 || + envelope.Data.Warnings[0] != "the target block is not replaceable" { + t.Fatalf("envelope = %+v", envelope) + } + }) + } +} diff --git a/shortcuts/doc/docs_script.go b/shortcuts/doc/docs_script.go new file mode 100644 index 0000000000..76bef5e591 --- /dev/null +++ b/shortcuts/doc/docs_script.go @@ -0,0 +1,895 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "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/cmdutil" + "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/doc/internal/docxparse" +) + +const ( + docsScriptParse = "parse" + docsScriptInitDraft = "init-draft" + docsScriptDraftDirectoryPattern = "draft_*_folder" + docsScriptDraftDirectoryPrefix = "draft_" + docsScriptDraftDirectorySuffix = "_folder" + docsScriptDraftXMLFileName = "draft.xml" + docsScriptDraftRandomHexLength = 8 + docsScriptDecisionFile = ".presentation-decision.json" + docsScriptDraftTip = "The workspace directory has been created successfully. draft_path points to a new XML file that does not exist yet. Create and write the file directly without reading it first." + docsScriptListBlockType = "list" + docsScriptAssessmentPassed = "passed" + docsScriptAssessmentFailed = "failed" + docsScriptDiagnosticError = "error" + docsScriptCodeWordCountRange = "word_count_out_of_range" + docsScriptCodeRequiredBlock = "required_block_missing" + docsScriptCodeResourcePreflight = "resource_preflight_failed" + docsScriptCodeImageSource = "remote_image_source_disallowed" + docsScriptCodeImageUnavailable = "remote_image_unavailable" + docsScriptCodeImageFormat = "remote_image_format_unsupported" + docsScriptCodeImageTooLarge = "remote_image_too_large" + docsScriptCodeImagePreflight = "remote_image_preflight_failed" +) + +var DocsScript = common.Shortcut{ + Service: "docs", + Command: "+script", + Description: "Initialize a document draft workspace, or parse, preflight, and profile documents", + 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{docsScriptInitDraft, docsScriptParse}, + }, + { + Name: "content", + Desc: "local XML content for parse; 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: "presentation-decision", + Desc: "Presentation Decision JSON required by init-draft and saved as the draft profile baseline; genre_contract and adapter accept a short name, \"none\", or null; accepts inline JSON (recommended for init-draft), @relative-file, or - for stdin", + Input: []string{common.File, common.Stdin}, + }, + }, + Tips: []string{ + "parse preflights draft resources when a Presentation Decision is loaded, repairs common malformed XML in memory, and returns the text and block profile", + "for parse results, ok reports command execution and data.assessment.status reports whether all available presentation and resource checks passed", + }, + PostMount: installDocsScriptHelp, + Validate: validateDocsScript, + DryRun: dryRunDocsScript, + Execute: executeDocsScript, +} + +type docsScriptParseResult struct { + Assessment docsScriptAssessment `json:"assessment"` + Profile docsScriptPublicProfile `json:"profile"` + Diagnostics []docsScriptDiagnostic `json:"diagnostics,omitempty"` +} + +type docsScriptAssessment struct { + Status string `json:"status"` +} + +type docsScriptDiagnostic struct { + Severity string `json:"severity"` + Code string `json:"code"` + Expected *docsScriptDiagnosticExpectation `json:"expected,omitempty"` + Actual *int `json:"actual,omitempty"` + ImageIndices []int `json:"image_indices,omitempty"` + Msg string `json:"msg"` + Suggested string `json:"suggested,omitempty"` +} + +type docsScriptDiagnosticExpectation struct { + Type string `json:"type,omitempty"` + MinCount int `json:"min_count,omitempty"` + Min *int `json:"min,omitempty"` + Max *int `json:"max,omitempty"` +} + +// 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 docsScriptPresentationDecision struct { + Audience string `json:"audience"` + ReaderTask string `json:"reader_task"` + GenreContract *string `json:"genre_contract"` + Adapter *string `json:"adapter"` + PresentationMode string `json:"presentation_mode"` + WordCount *docsScriptPresentationWordCount `json:"word_count"` + VisualPlan docsScriptPresentationVisualPlan `json:"visual_plan"` +} + +type docsScriptPresentationWordCount struct { + Min *int `json:"min"` + Max *int `json:"max"` +} + +type docsScriptPresentationVisualPlan struct { + Reason string `json:"reason"` + Blocks []docsScriptPresentationBlockRequirement `json:"blocks"` +} + +type docsScriptPresentationBlockRequirement struct { + Type string `json:"type"` + MinCount int `json:"min_count"` + Purpose string `json:"purpose"` +} + +type docsScriptDraftResult struct { + Workspace string `json:"workspace"` + DraftPath string `json:"draft_path"` + Tip string `json:"tip"` +} + +type docsScriptWorkspace struct { + path string + fileIO fileio.WorkspaceFileIO +} + +type docsScriptFetchRequest struct { + Format string `json:"format"` + ExtraParam string `json:"extra_param"` + ExportOption docsScriptFetchExportOption `json:"export_option"` + Lang string `json:"lang,omitempty"` +} + +type docsScriptFetchExportOption struct { + ExportBlockID bool `json:"export_block_id"` + ExportStyleAttrs bool `json:"export_style_attrs"` + ExportCiteExtraData bool `json:"export_cite_extra_data"` +} + +type docsScriptFetchResponse struct { + Document *struct { + Content *string `json:"content"` + } `json:"document"` +} + +func installDocsScriptHelp(cmd *cobra.Command) { + installDocsContentPathCapture(cmd) + cmd.Example = ` lark-cli docs +script --command init-draft --presentation-decision '' + lark-cli docs +script --command parse --content "@./draft.xml" + lark-cli docs +script --command parse --doc "https://example.larksuite.com/docx/doxcn..."` +} + +func validateDocsScript(_ context.Context, runtime *common.RuntimeContext) error { + command := runtime.Str("command") + content := strings.TrimSpace(runtime.Str("content")) + doc := strings.TrimSpace(runtime.Str("doc")) + presentationDecision := strings.TrimSpace(runtime.Str("presentation-decision")) + if presentationDecision != "" { + if command != docsScriptParse && command != docsScriptInitDraft { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision is only supported with --command init-draft or parse").WithParam("--presentation-decision") + } + if _, err := parseDocsScriptPresentationDecision(presentationDecision); err != nil { + return err + } + } + if command == docsScriptInitDraft { + switch { + case content != "": + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--content is not supported with --command %s", command).WithParam("--content") + case doc != "": + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--doc is not supported with --command %s", command).WithParam("--doc") + case presentationDecision == "": + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision is required with --command init-draft").WithParam("--presentation-decision") + } + return nil + } + 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 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 command == docsScriptParse { + if _, _, err := resolveDocsScriptPresentationDecision(runtime); err != nil { + return err + } + } + return nil +} + +func dryRunDocsScript(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + if runtime.Str("command") == docsScriptInitDraft { + dry := common.NewDryRunAPI(). + Desc("Create a unique draft workspace, preserve its Presentation Decision, and reserve a new XML path without creating the XML; no API call is made"). + Set("command", docsScriptInitDraft). + Set("directory_pattern", docsScriptDraftDirectoryPattern). + Set("xml_file_name", docsScriptDraftXMLFileName). + Set("creates_workspace", true). + Set("creates_draft_file", false). + Set("presentation_decision", true). + Set("network", false) + return dry + } + if doc := strings.TrimSpace(runtime.Str("doc")); doc != "" { + ref, _ := parseDocumentRef(doc) + dry := 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) + if strings.TrimSpace(runtime.Str("presentation-decision")) != "" { + dry.Set("presentation_decision", true) + } + return dry + } + dry := common.NewDryRunAPI(). + Desc("LarkOpenCLI XML parsing; no OpenAPI call is made"). + Set("command", runtime.Str("command")). + Set("input_bytes", len(runtime.Str("content"))) + hasDecision := docsScriptHasPresentationDecision(runtime) + network := hasDecision && docsScriptHasRemoteImagePreflight(runtime, runtime.Str("content")) + dry.Set("network", network) + if network { + dry.Desc("LarkOpenCLI XML parsing; remote image availability preflight makes ranged network requests but does not buffer image bodies") + } + if hasDecision { + dry.Set("presentation_decision", true) + } + return dry +} + +func executeDocsScript(_ context.Context, runtime *common.RuntimeContext) error { + command := runtime.Str("command") + content := runtime.Str("content") + switch command { + case docsScriptInitDraft: + return initDocsScriptDraft(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.ParseCompatibleXML(content) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "could not parse %s as LarkOpenCLI XML: %s", inputLabel, err). + WithParam(inputParam). + WithCause(err) + } + publicProfile := docsScriptPublicProfile{ + WordCount: profile.WordCount, + CharCount: profile.CharCount, + BlockCount: profile.BlockCount, + Blocks: profile.Blocks, + } + decision, hasDecision, err := resolveDocsScriptPresentationDecision(runtime) + if err != nil { + return err + } + var diagnostics []docsScriptDiagnostic + if hasDecision { + diagnostics = docsScriptPresentationDiagnostics(publicProfile, decision) + diagnostics = append(diagnostics, docsScriptResourceDiagnostics(runtime, content)...) + } + status := docsScriptAssessmentPassed + if len(diagnostics) > 0 { + status = docsScriptAssessmentFailed + } + result := docsScriptParseResult{ + Assessment: docsScriptAssessment{Status: status}, + Profile: publicProfile, + Diagnostics: diagnostics, + } + runtime.OutFormatRaw(result, nil, nil) + return nil + default: + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "unsupported --command %q", command). + WithParam("--command") + } +} + +func docsScriptResourceDiagnostics(runtime *common.RuntimeContext, content string) []docsScriptDiagnostic { + input, err := prepareDocsV2WriteInputForFormat(runtime, string(docxparse.FormatXML), docsV2WriteInput{Content: content}) + if err != nil { + return []docsScriptDiagnostic{docsScriptResourceDiagnostic(err)} + } + + diagnostics := make([]docsScriptDiagnostic, 0) + groupByCause := make(map[string]int) + for _, resource := range input.LocalResources { + if resource.RemoteURL == "" { + continue + } + if err := probeRemoteDocImageDownload(runtime, resource.RemoteURL, resource.Occurrence); err != nil { + diagnostic := docsScriptRemoteImageDiagnostic(err, resource.Occurrence) + groupKey := diagnostic.Code + "\x00" + diagnostic.Msg + "\x00" + diagnostic.Suggested + if index, ok := groupByCause[groupKey]; ok { + diagnostics[index].ImageIndices = append(diagnostics[index].ImageIndices, resource.Occurrence) + continue + } + groupByCause[groupKey] = len(diagnostics) + diagnostics = append(diagnostics, diagnostic) + } + } + return diagnostics +} + +func docsScriptHasPresentationDecision(runtime *common.RuntimeContext) bool { + _, hasDecision, err := resolveDocsScriptPresentationDecision(runtime) + return err == nil && hasDecision +} + +func docsScriptHasRemoteImagePreflight(runtime *common.RuntimeContext, content string) bool { + input, err := prepareDocsV2WriteInputForFormat(runtime, string(docxparse.FormatXML), docsV2WriteInput{Content: content}) + if err != nil { + return false + } + for _, resource := range input.LocalResources { + if resource.RemoteURL != "" { + return true + } + } + return false +} + +func docsScriptResourceDiagnostic(err error) docsScriptDiagnostic { + diagnostic := docsScriptDiagnostic{ + Severity: docsScriptDiagnosticError, + Code: docsScriptCodeResourcePreflight, + Msg: err.Error(), + } + if problem, ok := errs.ProblemOf(err); ok { + diagnostic.Msg = problem.Message + diagnostic.Suggested = problem.Hint + } + return diagnostic +} + +func docsScriptRemoteImageDiagnostic(err error, occurrence int) docsScriptDiagnostic { + message := err.Error() + code := docsScriptCodeImagePreflight + suggested := "Download the affected images into the draft workspace, then replace href with ." + problem, hasProblem := errs.ProblemOf(err) + if hasProblem { + message = problem.Message + } + switch { + case strings.Contains(message, "href is not allowed") || + strings.Contains(message, "href must be an absolute") || + strings.Contains(message, "invalid remote image"): + code = docsScriptCodeImageSource + case strings.Contains(message, "exceeds 20MiB"): + code = docsScriptCodeImageTooLarge + suggested = "Compress the affected images below 20 MiB, save them in the draft workspace, and use ." + case strings.Contains(message, "Content-Type") || + strings.Contains(message, "not a valid") || + strings.Contains(message, "declared"): + code = docsScriptCodeImageFormat + suggested = "Convert the affected images to BMP, GIF, JPEG, PNG, TIFF, or WebP, save them in the draft workspace, and use ." + case hasProblem && problem.Category == errs.CategoryNetwork: + code = docsScriptCodeImageUnavailable + suggested = "Check that the affected image URLs are publicly reachable, or download the images into the draft workspace and use ." + } + return docsScriptDiagnostic{ + Severity: docsScriptDiagnosticError, + Code: code, + ImageIndices: []int{occurrence}, + Msg: docsScriptRemoteImageReason(message, occurrence), + Suggested: suggested, + } +} + +func docsScriptRemoteImageReason(message string, occurrence int) string { + prefixes := []string{ + fmt.Sprintf("remote image #%d href is not allowed: ", occurrence), + fmt.Sprintf("remote image #%d href ", occurrence), + fmt.Sprintf("invalid remote image #%d href: ", occurrence), + fmt.Sprintf("probe remote image #%d failed: ", occurrence), + fmt.Sprintf("download remote image #%d failed: ", occurrence), + fmt.Sprintf("download remote image #%d ", occurrence), + fmt.Sprintf("remote image #%d ", occurrence), + } + message = strings.TrimSpace(message) + for _, prefix := range prefixes { + if strings.HasPrefix(message, prefix) { + return strings.TrimSpace(strings.TrimPrefix(message, prefix)) + } + } + return message +} + +func resolveDocsScriptPresentationDecision(runtime *common.RuntimeContext) (docsScriptPresentationDecision, bool, error) { + if rawDecision := strings.TrimSpace(runtime.Str("presentation-decision")); rawDecision != "" { + decision, err := parseDocsScriptPresentationDecision(rawDecision) + return decision, err == nil, err + } + contentPath, ok := runtime.Cmd.Annotations[docsContentPathAnnotation] + if !ok { + return docsScriptPresentationDecision{}, false, nil + } + rawDecision, err := cmdutil.ReadInputFile(runtime.FileIO(), filepath.Join(filepath.Dir(contentPath), docsScriptDecisionFile)) + if errors.Is(err, fs.ErrNotExist) { + return docsScriptPresentationDecision{}, false, nil + } + if err != nil { + return docsScriptPresentationDecision{}, false, errs.NewInternalError(errs.SubtypeFileIO, + "read saved Presentation Decision: %s", err). + WithCause(err) + } + decision, err := parseDocsScriptPresentationDecision(string(rawDecision)) + if err != nil { + return docsScriptPresentationDecision{}, false, errs.NewInternalError(errs.SubtypeUnknown, + "saved Presentation Decision is invalid"). + WithCause(err) + } + return decision, true, nil +} + +func parseDocsScriptPresentationDecision(raw string) (docsScriptPresentationDecision, error) { + var decision docsScriptPresentationDecision + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&decision); err != nil { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision must be a valid Presentation Decision JSON object: %s", err). + WithParam("--presentation-decision"). + WithCause(err) + } + var trailing interface{} + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision must contain exactly one JSON object; multiple JSON values were provided"). + WithParam("--presentation-decision") + } + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision must contain exactly one JSON object: %s", err). + WithParam("--presentation-decision"). + WithCause(err) + } + var rawFields map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &rawFields); err != nil { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision must be a valid Presentation Decision JSON object: %s", err). + WithParam("--presentation-decision"). + WithCause(err) + } + for _, field := range []string{ + "audience", "reader_task", "genre_contract", "adapter", "presentation_mode", + "visual_plan", + } { + if _, ok := rawFields[field]; !ok { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision %s is required", field). + WithParam("--presentation-decision") + } + } + if rawWordCountValue, hasWordCount := rawFields["word_count"]; hasWordCount { + if strings.TrimSpace(string(rawWordCountValue)) == "null" { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision word_count must be omitted when no word-count requirement was requested"). + WithParam("--presentation-decision"). + WithHint("remove the word_count field; include it only when the user requested a word-count bound") + } + if decision.WordCount == nil { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision word_count must be an object containing min and max"). + WithParam("--presentation-decision") + } + var rawWordCountFields map[string]json.RawMessage + if err := json.Unmarshal(rawWordCountValue, &rawWordCountFields); err != nil { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision word_count must be an object containing min and max"). + WithParam("--presentation-decision"). + WithCause(err) + } + for _, field := range []string{"min", "max"} { + if _, ok := rawWordCountFields[field]; !ok { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision word_count.%s is required; use null when that bound is unspecified", field). + WithParam("--presentation-decision") + } + } + switch { + case decision.WordCount.Min == nil && decision.WordCount.Max == nil: + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision word_count must set at least one of min or max; omit word_count when no word count was requested"). + WithParam("--presentation-decision") + case decision.WordCount.Min != nil && *decision.WordCount.Min <= 0: + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision word_count.min must be a positive integer when provided"). + WithParam("--presentation-decision") + case decision.WordCount.Max != nil && *decision.WordCount.Max <= 0: + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision word_count.max must be a positive integer when provided"). + WithParam("--presentation-decision") + case decision.WordCount.Min != nil && decision.WordCount.Max != nil && *decision.WordCount.Min > *decision.WordCount.Max: + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision word_count.min must not exceed word_count.max"). + WithParam("--presentation-decision") + } + } + decision.Audience = strings.TrimSpace(decision.Audience) + decision.ReaderTask = strings.TrimSpace(decision.ReaderTask) + var err error + decision.GenreContract, err = normalizeDocsScriptOptionalRoute("genre_contract", decision.GenreContract) + if err != nil { + return docsScriptPresentationDecision{}, err + } + decision.Adapter, err = normalizeDocsScriptOptionalRoute("adapter", decision.Adapter) + if err != nil { + return docsScriptPresentationDecision{}, err + } + decision.PresentationMode = strings.TrimSpace(decision.PresentationMode) + decision.VisualPlan.Reason = strings.TrimSpace(decision.VisualPlan.Reason) + requiredStrings := []struct { + field string + value string + }{ + {"audience", decision.Audience}, + {"reader_task", decision.ReaderTask}, + {"presentation_mode", decision.PresentationMode}, + {"visual_plan.reason", decision.VisualPlan.Reason}, + } + for _, required := range requiredStrings { + if required.value == "" { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision %s must not be empty", required.field). + WithParam("--presentation-decision") + } + } + var rawVisualPlan map[string]json.RawMessage + if err := json.Unmarshal(rawFields["visual_plan"], &rawVisualPlan); err != nil { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision visual_plan must be an object containing reason and blocks"). + WithParam("--presentation-decision"). + WithCause(err) + } + for _, field := range []string{"reason", "blocks"} { + if _, ok := rawVisualPlan[field]; !ok { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision visual_plan.%s is required", field). + WithParam("--presentation-decision") + } + } + if decision.VisualPlan.Blocks == nil { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision visual_plan.blocks must be an array; use [] when no presentation blocks are planned"). + WithParam("--presentation-decision") + } + switch decision.PresentationMode { + case "formal", "normal", "rich": + default: + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision presentation_mode must be formal, normal, or rich"). + WithParam("--presentation-decision") + } + seenBlockTypes := make(map[string]struct{}, len(decision.VisualPlan.Blocks)) + for i := range decision.VisualPlan.Blocks { + requirement := &decision.VisualPlan.Blocks[i] + requirement.Type = strings.TrimSpace(requirement.Type) + requirement.Purpose = strings.TrimSpace(requirement.Purpose) + if requirement.Type != docsScriptListBlockType && !docxparse.IsPresentationBlockType(requirement.Type) { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision visual_plan.blocks[%d].type %q is not a presentation block type", i, requirement.Type). + WithParam("--presentation-decision") + } + if _, exists := seenBlockTypes[requirement.Type]; exists { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision visual_plan.blocks contains duplicate type %q; combine it into one minimum", requirement.Type). + WithParam("--presentation-decision") + } + seenBlockTypes[requirement.Type] = struct{}{} + if requirement.MinCount <= 0 { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision visual_plan.blocks[%d].min_count must be positive", i). + WithParam("--presentation-decision") + } + if requirement.Purpose == "" { + return docsScriptPresentationDecision{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision visual_plan.blocks[%d].purpose must not be empty", i). + WithParam("--presentation-decision") + } + } + return decision, nil +} + +func normalizeDocsScriptOptionalRoute(field string, value *string) (*string, error) { + if value == nil { + return nil, nil + } + normalized := strings.TrimSpace(*value) + if normalized == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--presentation-decision %s must be a non-empty short name, \"none\", or null", field). + WithParam("--presentation-decision") + } + return &normalized, nil +} + +func docsScriptPresentationDiagnostics(profile docsScriptPublicProfile, decision docsScriptPresentationDecision) []docsScriptDiagnostic { + var diagnostics []docsScriptDiagnostic + if decision.WordCount != nil { + belowMinimum := decision.WordCount.Min != nil && profile.WordCount < *decision.WordCount.Min + aboveMaximum := decision.WordCount.Max != nil && profile.WordCount > *decision.WordCount.Max + if belowMinimum || aboveMaximum { + expectation := docsScriptDiagnosticExpectation{ + Min: decision.WordCount.Min, + Max: decision.WordCount.Max, + } + suggested := "" + switch { + case decision.WordCount.Min != nil && decision.WordCount.Max != nil: + suggested = fmt.Sprintf("Adjust word_count to the %d-%d range.", *decision.WordCount.Min, *decision.WordCount.Max) + case decision.WordCount.Min != nil: + suggested = fmt.Sprintf("Increase word_count to at least %d.", *decision.WordCount.Min) + default: + suggested = fmt.Sprintf("Reduce word_count to at most %d.", *decision.WordCount.Max) + } + actual := profile.WordCount + diagnostics = append(diagnostics, docsScriptDiagnostic{ + Severity: docsScriptDiagnosticError, + Code: docsScriptCodeWordCountRange, + Expected: &expectation, + Actual: &actual, + Msg: "word_count does not satisfy the Presentation Decision.", + Suggested: suggested, + }) + } + } + for _, required := range decision.VisualPlan.Blocks { + actual := docsScriptBlockCount(profile.Blocks, required.Type) + if actual < required.MinCount { + diagnostics = append(diagnostics, docsScriptDiagnostic{ + Severity: docsScriptDiagnosticError, + Code: docsScriptCodeRequiredBlock, + Expected: &docsScriptDiagnosticExpectation{ + Type: required.Type, + MinCount: required.MinCount, + }, + Actual: &actual, + Msg: fmt.Sprintf("The draft is missing required %s block(s) for %s.", required.Type, required.Purpose), + Suggested: fmt.Sprintf("Add at least %d %s block(s) for %s.", required.MinCount, required.Type, required.Purpose), + }) + } + } + return diagnostics +} + +func docsScriptBlockCount(blocks []docxparse.BlockShare, blockType string) int { + if blockType == docsScriptListBlockType { + return docsScriptBlockCount(blocks, "ul") + docsScriptBlockCount(blocks, "ol") + } + for _, block := range blocks { + if block.Type == blockType { + return block.Count + } + } + return 0 +} + +func initDocsScriptDraft(runtime *common.RuntimeContext) error { + workspace, err := newDocsScriptWorkspace(runtime) + if err != nil { + return err + } + rawDecision := strings.TrimSpace(runtime.Str("presentation-decision")) + if err := workspace.savePresentationDecision(rawDecision); err != nil { + return workspace.fail(common.WrapSaveErrorTyped(err)) + } + runtime.Out(docsScriptDraftResult{ + Workspace: workspace.directory(), + DraftPath: workspace.path, + Tip: docsScriptDraftTip, + }, nil) + if err := runtime.OutputError(); err != nil { + return workspace.fail(err) + } + return nil +} + +func newDocsScriptWorkspace(runtime *common.RuntimeContext) (docsScriptWorkspace, error) { + path, err := newDocsScriptWorkspacePath() + if err != nil { + return docsScriptWorkspace{}, errs.NewInternalError(errs.SubtypeUnknown, + "generate unique draft workspace path").WithCause(err) + } + resolvedFileIO := runtime.FileIO() + if resolvedFileIO == nil { + return docsScriptWorkspace{}, errs.NewInternalError(errs.SubtypeFileIO, + "resolve reserved draft XML path %s: no file I/O provider registered", path) + } + workspaceFileIO, ok := resolvedFileIO.(fileio.WorkspaceFileIO) + if !ok { + return docsScriptWorkspace{}, errs.NewValidationError(errs.SubtypeFailedPrecondition, + "configured file I/O provider does not support draft workspace cleanup"). + WithHint("use a file I/O provider that supports non-recursive workspace entry removal") + } + resolvedPath, err := workspaceFileIO.ResolvePath(path) + if err != nil { + return docsScriptWorkspace{}, errs.NewInternalError(errs.SubtypeFileIO, + "resolve reserved draft XML path %s: %s", path, err). + WithCause(err) + } + if resolvedPath == "" { + return docsScriptWorkspace{}, errs.NewInternalError(errs.SubtypeFileIO, + "resolve reserved draft XML path %s: empty result", path) + } + return docsScriptWorkspace{ + path: path, + fileIO: workspaceFileIO, + }, nil +} + +func (workspace docsScriptWorkspace) directory() string { + return filepath.Dir(workspace.path) +} + +func (workspace docsScriptWorkspace) savePresentationDecision(decision string) error { + _, err := workspace.fileIO.Save(filepath.Join(workspace.directory(), docsScriptDecisionFile), fileio.SaveOptions{ + ContentType: "application/json", + ContentLength: int64(len(decision)), + }, strings.NewReader(decision)) + return err +} + +func (workspace docsScriptWorkspace) fail(cause error) error { + if cleanupErr := workspace.remove(); cleanupErr != nil { + return errors.Join(cause, cleanupErr) + } + return cause +} + +func (workspace docsScriptWorkspace) remove() error { + cleanPath := filepath.Clean(workspace.path) + if !isDocsScriptWorkspacePath(cleanPath) { + return errs.NewInternalError(errs.SubtypeFileIO, + "refusing to remove unexpected draft workspace path %s", filepath.Dir(cleanPath)) + } + for _, entry := range []string{ + filepath.Join(filepath.Dir(cleanPath), docsScriptDecisionFile), + filepath.Dir(cleanPath), + } { + if err := workspace.fileIO.RemoveWorkspaceEntry(entry); err != nil && !errors.Is(err, fs.ErrNotExist) { + return errs.NewInternalError(errs.SubtypeFileIO, + "remove failed draft workspace entry %s: %s", entry, err). + WithCause(err) + } + } + return nil +} + +func isDocsScriptWorkspacePath(path string) bool { + directory := filepath.Dir(path) + directoryName := filepath.Base(directory) + randomPart := strings.TrimSuffix(strings.TrimPrefix(directoryName, docsScriptDraftDirectoryPrefix), docsScriptDraftDirectorySuffix) + _, randomErr := hex.DecodeString(randomPart) + return filepath.Dir(directory) == "." && + filepath.Base(path) == docsScriptDraftXMLFileName && + strings.HasPrefix(directoryName, docsScriptDraftDirectoryPrefix) && strings.HasSuffix(directoryName, docsScriptDraftDirectorySuffix) && + len(randomPart) == docsScriptDraftRandomHexLength && randomErr == nil +} + +func newDocsScriptWorkspacePath() (string, error) { + random := make([]byte, docsScriptDraftRandomHexLength/2) + if _, err := rand.Read(random); err != nil { + return "", err + } + directory := docsScriptDraftDirectoryPrefix + hex.EncodeToString(random) + docsScriptDraftDirectorySuffix + return filepath.Join(directory, docsScriptDraftXMLFileName), nil +} + +func docsScriptFetchBody(runtime *common.RuntimeContext) docsScriptFetchRequest { + body := docsScriptFetchRequest{ + Format: "xml", + ExtraParam: docsFetchExtraParam, + ExportOption: docsScriptFetchExportOption{ + ExportBlockID: false, + ExportStyleAttrs: false, + ExportCiteExtraData: 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 := runtime.CallAPITyped("POST", apiPath, nil, docsScriptFetchBody(runtime)) + if err != nil { + return "", err + } + response, err := projectDocsScriptFetchResponse(data) + if err != nil { + return "", err + } + if response.Document == nil { + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, + "document fetch response for --doc is missing document") + } + if response.Document.Content == nil { + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, + "document fetch response for --doc is missing document.content") + } + return *response.Document.Content, nil +} + +func projectDocsScriptFetchResponse(data map[string]interface{}) (docsScriptFetchResponse, error) { + raw, err := json.Marshal(data) + if err != nil { + return docsScriptFetchResponse{}, errs.NewInternalError(errs.SubtypeInvalidResponse, + "encode document fetch response: %v", err).WithCause(err) + } + var response docsScriptFetchResponse + if err := json.Unmarshal(raw, &response); err != nil { + return docsScriptFetchResponse{}, errs.NewInternalError(errs.SubtypeInvalidResponse, + "decode document fetch response: %v", err).WithCause(err) + } + return response, nil +} diff --git a/shortcuts/doc/docs_script_test.go b/shortcuts/doc/docs_script_test.go new file mode 100644 index 0000000000..acf9463bbe --- /dev/null +++ b/shortcuts/doc/docs_script_test.go @@ -0,0 +1,1317 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/doc/internal/docxparse" +) + +func TestDocsScriptDoesNotExposeRemovedCommandsOrFlags(t *testing.T) { + foundCommand := false + for _, flag := range DocsScript.Flags { + switch flag.Name { + case "strict", "output", "overwrite", "file-name": + t.Fatalf("docs +script still exposes removed --%s flag", flag.Name) + case "command": + foundCommand = true + for _, value := range flag.Enum { + if value == "markdown-to-xml" || value == "create-temp-xml" { + t.Fatalf("docs +script still exposes removed %s command", value) + } + } + } + } + if !foundCommand { + t.Fatal("docs +script does not expose --command") + } +} + +func TestDocsScriptPresentationDecisionFlagAcceptsFileAndStdin(t *testing.T) { + for _, flag := range DocsScript.Flags { + if flag.Name != "presentation-decision" { + continue + } + if len(flag.Input) != 2 || flag.Input[0] != common.File || flag.Input[1] != common.Stdin { + t.Fatalf("presentation-decision Input = %#v, want file and stdin", flag.Input) + } + for _, want := range []string{"genre_contract and adapter", `"none"`, "or null"} { + if !strings.Contains(flag.Desc, want) { + t.Fatalf("presentation-decision help = %q, want it to contain %q", flag.Desc, want) + } + } + return + } + t.Fatal("docs +script does not expose --presentation-decision") +} + +func TestDocsScriptParsesAndProfilesXML(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-test")) + source := `标题

一个苹果是 an apple。

` + + 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) != 2 || envelope.Data["profile"] == nil || envelope.Data["assessment"] == nil { + t.Fatalf("data = %+v, want assessment and profile", envelope.Data) + } + var assessment docsScriptAssessment + if err := json.Unmarshal(envelope.Data["assessment"], &assessment); err != nil { + t.Fatalf("decode assessment: %v", err) + } + if assessment.Status != docsScriptAssessmentPassed { + t.Fatalf("assessment = %+v for valid XML: %s", assessment, stdout) + } + 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 TestDocsScriptReturnsPresentationDecisionDiagnostics(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-presentation-warnings")) + decision := `{ + "audience": "reader", + "reader_task": "understand the result and flow", + "genre_contract": "none", + "adapter": null, + "presentation_mode": "rich", + "word_count": {"min": 18, "max": 22}, + "visual_plan": { + "reason": "visual explanation", + "blocks": [ + {"type":"img","min_count":1,"purpose":"show the result"}, + {"type":"whiteboard","min_count":1,"purpose":"show the flow"}, + {"type":"html5-block","min_count":1,"purpose":"make the state explorable"} + ] + } + }` + + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptParse, + "--content", `标题

一个苹果是 an apple。

`, + "--presentation-decision", decision, + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("execute docs +script: %v", err) + } + + var envelope struct { + OK bool `json:"ok"` + Data docsScriptParseResult `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("diagnostic result reported ok:false: %s", stdout) + } + if envelope.Data.Assessment.Status != docsScriptAssessmentFailed { + t.Fatalf("assessment = %+v with diagnostics, want failed: %s", envelope.Data.Assessment, stdout) + } + if len(envelope.Data.Diagnostics) != 3 { + t.Fatalf("diagnostics = %#v, want word-count, whiteboard, and html diagnostics", envelope.Data.Diagnostics) + } + wordCount := envelope.Data.Diagnostics[0] + if wordCount.Severity != docsScriptDiagnosticError || wordCount.Code != docsScriptCodeWordCountRange || + wordCount.Expected == nil || wordCount.Expected.Min == nil || *wordCount.Expected.Min != 18 || + wordCount.Expected.Max == nil || *wordCount.Expected.Max != 22 || wordCount.Actual == nil || *wordCount.Actual != 10 { + t.Fatalf("word-count diagnostic = %#v", wordCount) + } + for index, wantType := range []string{"whiteboard", "html5-block"} { + diagnostic := envelope.Data.Diagnostics[index+1] + if diagnostic.Code != docsScriptCodeRequiredBlock || diagnostic.Expected == nil || + diagnostic.Expected.Type != wantType || diagnostic.Expected.MinCount != 1 || + diagnostic.Actual == nil || *diagnostic.Actual != 0 { + t.Errorf("%s diagnostic = %#v", wantType, diagnostic) + } + } + for _, diagnostic := range envelope.Data.Diagnostics { + if diagnostic.Expected != nil && diagnostic.Expected.Type == "img" { + t.Fatalf("diagnostics = %#v, img requirement is satisfied", envelope.Data.Diagnostics) + } + } +} + +func TestDocsScriptPresentationDecisionPreflightsBlockedRemoteImage(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-resource-preflight")) + decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"an image provides visual evidence","blocks":[{"type":"img","min_count":1,"purpose":"show visual evidence"}]}}` + + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptParse, + "--content", `Draft`, + "--presentation-decision", decision, + "--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.Assessment.Status != docsScriptAssessmentFailed { + t.Fatalf("assessment = %+v, want failed", envelope.Data.Assessment) + } + if len(envelope.Data.Diagnostics) != 1 { + t.Fatalf("diagnostics = %#v, want one deduplicated image diagnostic", envelope.Data.Diagnostics) + } + diagnostic := envelope.Data.Diagnostics[0] + if diagnostic.Code != docsScriptCodeImageSource || diagnostic.Severity != docsScriptDiagnosticError || + len(diagnostic.ImageIndices) != 2 || diagnostic.ImageIndices[0] != 1 || diagnostic.ImageIndices[1] != 2 || + diagnostic.Msg != "local/internal host is not allowed" || !strings.Contains(diagnostic.Suggested, "Download the affected images") { + t.Fatalf("image diagnostic = %#v", diagnostic) + } + if strings.Contains(diagnostic.Msg, "remote image #") || strings.Contains(stdout.String(), "resource preflight failed") { + t.Fatalf("image diagnostic repeats legacy prefixes: %s", stdout) + } +} + +func TestDocsScriptPresentationDecisionProbesRemoteImageDownload(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-resource-download-probe")) + decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"an image provides visual evidence","blocks":[{"type":"img","min_count":1,"purpose":"show visual evidence"}]}}` + + originalDo := doRemoteDocImageRequest + t.Cleanup(func() { doRemoteDocImageRequest = originalDo }) + requestMethod := "" + requestRange := "" + doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) { + requestMethod = req.Method + requestRange = req.Header.Get("Range") + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: http.Header{"Content-Type": []string{"image/png"}}, + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptParse, + "--content", `Draft`, + "--presentation-decision", decision, + }, 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 requestMethod != http.MethodGet || requestRange != "bytes=0-0" { + t.Fatalf("remote image probe method = %q range = %q, want ranged GET", requestMethod, requestRange) + } + if envelope.Data.Assessment.Status != docsScriptAssessmentFailed { + t.Fatalf("assessment = %+v, want failed", envelope.Data.Assessment) + } + if len(envelope.Data.Diagnostics) != 1 || envelope.Data.Diagnostics[0].Code != docsScriptCodeImageUnavailable || + len(envelope.Data.Diagnostics[0].ImageIndices) != 1 || envelope.Data.Diagnostics[0].ImageIndices[0] != 1 || + envelope.Data.Diagnostics[0].Msg != "HTTP 404" { + t.Fatalf("diagnostics = %#v, want image #1 HTTP 404", envelope.Data.Diagnostics) + } +} + +func TestDocsScriptParseWithoutPresentationDecisionSkipsResourcePreflight(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-no-resource-preflight")) + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptParse, + "--content", `Draft`, + "--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.Assessment.Status != docsScriptAssessmentPassed || len(envelope.Data.Diagnostics) != 0 { + t.Fatalf("result = %#v, want passed with no resource preflight without a Presentation Decision", envelope.Data) + } +} + +func TestDocsScriptInitDraftPersistsDecisionForAutomaticParse(t *testing.T) { + workDir := t.TempDir() + withDocsWorkingDir(t, workDir) + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-init-draft")) + decision := `{"audience":"reader","reader_task":"understand the result and flow","genre_contract":"none","adapter":null,"presentation_mode":"rich","word_count":{"min":18,"max":22},"visual_plan":{"reason":"visual explanation","blocks":[{"type":"img","min_count":1,"purpose":"show the result"},{"type":"whiteboard","min_count":1,"purpose":"show the flow"},{"type":"html5-block","min_count":1,"purpose":"make the state explorable"}]}}` + + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptInitDraft, + "--presentation-decision", decision, + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("initialize draft: %v", err) + } + var initialized struct { + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &initialized); err != nil { + t.Fatalf("decode init output: %v\n%s", err, stdout) + } + if len(initialized.Data) != 3 || initialized.Data["workspace"] == nil || initialized.Data["draft_path"] == nil || initialized.Data["tip"] == nil { + t.Fatalf("init data = %#v, want workspace, draft_path, and tip", initialized.Data) + } + var result docsScriptDraftResult + rawResult, err := json.Marshal(initialized.Data) + if err != nil { + t.Fatalf("encode init data: %v", err) + } + if err := json.Unmarshal(rawResult, &result); err != nil { + t.Fatalf("decode init data: %v", err) + } + draftPath := result.DraftPath + if result.Tip != docsScriptDraftTip { + t.Fatalf("tip = %q, want %q", result.Tip, docsScriptDraftTip) + } + if result.Workspace != filepath.Dir(draftPath) { + t.Fatalf("workspace = %q, want parent of draft_path %q", result.Workspace, draftPath) + } + if filepath.IsAbs(result.Workspace) || filepath.Dir(result.Workspace) != "." { + t.Fatalf("workspace = %q, want a generated relative directory", result.Workspace) + } + if filepath.IsAbs(draftPath) || filepath.Dir(draftPath) == "." { + t.Fatalf("draft path = %q, want a relative path inside a generated workspace", draftPath) + } + if _, err := os.Stat(draftPath); !os.IsNotExist(err) { + t.Fatalf("reserved draft XML path already exists: %q, err=%v", draftPath, err) + } + savedDecision, err := os.ReadFile(filepath.Join(filepath.Dir(draftPath), docsScriptDecisionFile)) + if err != nil { + t.Fatalf("read saved decision: %v", err) + } + if string(savedDecision) != decision { + t.Fatalf("saved decision = %q, want %q", savedDecision, decision) + } + if err := os.WriteFile(draftPath, []byte(`标题

一个苹果是 an apple。

`), 0o600); err != nil { + t.Fatalf("write draft: %v", err) + } + + stdout.Reset() + err = mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptParse, + "--content", "@./" + draftPath, + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("execute docs +script: %v", err) + } + var parsed struct { + Data docsScriptParseResult `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &parsed); err != nil { + t.Fatalf("decode parse output: %v\n%s", err, stdout) + } + if parsed.Data.Assessment.Status != docsScriptAssessmentFailed || len(parsed.Data.Diagnostics) != 3 { + t.Fatalf("result = %#v, want failed assessment with persisted word-count, whiteboard, and html diagnostics", parsed.Data) + } +} + +func TestDocsScriptInitDraftRequiresPresentationDecision(t *testing.T) { + workDir := t.TempDir() + withDocsWorkingDir(t, workDir) + f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-init-draft-required")) + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptInitDraft, + "--as", "bot", + }, f, nil) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--presentation-decision") + entries, readErr := os.ReadDir(workDir) + if readErr != nil { + t.Fatalf("read work directory: %v", readErr) + } + if len(entries) != 0 { + t.Fatalf("failed init created files: %#v", entries) + } +} + +func TestDocsScriptPresentationDecisionWordCountRangeIsInclusive(t *testing.T) { + minimum, maximum := 9, 11 + decision := docsScriptPresentationDecision{WordCount: &docsScriptPresentationWordCount{Min: &minimum, Max: &maximum}} + tests := []struct { + wordCount int + wantWarn bool + }{ + {wordCount: 8, wantWarn: true}, + {wordCount: 9, wantWarn: false}, + {wordCount: 10, wantWarn: false}, + {wordCount: 11, wantWarn: false}, + {wordCount: 12, wantWarn: true}, + } + for _, test := range tests { + t.Run(fmt.Sprintf("word-count-%d", test.wordCount), func(t *testing.T) { + diagnostics := docsScriptPresentationDiagnostics(docsScriptPublicProfile{WordCount: test.wordCount}, decision) + if got := len(diagnostics) > 0; got != test.wantWarn { + t.Fatalf("diagnostics = %#v, want diagnostic=%v", diagnostics, test.wantWarn) + } + }) + } +} + +func TestDocsScriptPresentationDecisionSupportsOneSidedWordCountBounds(t *testing.T) { + tests := []struct { + name string + wordCount int + decision string + want string + }{ + { + name: "minimum", + wordCount: 9, + decision: `{"audience":"reader","reader_task":"understand","genre_contract":"knowledge","adapter":null,"presentation_mode":"normal","word_count":{"min":10,"max":null},"visual_plan":{"reason":"plain text is sufficient","blocks":[]}}`, + want: "Increase word_count to at least 10.", + }, + { + name: "maximum", + wordCount: 21, + decision: `{"audience":"reader","reader_task":"understand","genre_contract":"knowledge","adapter":"wechat","presentation_mode":"normal","word_count":{"min":null,"max":20},"visual_plan":{"reason":"plain text is sufficient","blocks":[]}}`, + want: "Reduce word_count to at most 20.", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + decision, err := parseDocsScriptPresentationDecision(test.decision) + if err != nil { + t.Fatalf("parse decision: %v", err) + } + diagnostics := docsScriptPresentationDiagnostics(docsScriptPublicProfile{WordCount: test.wordCount}, decision) + if len(diagnostics) != 1 || diagnostics[0].Code != docsScriptCodeWordCountRange || diagnostics[0].Suggested != test.want { + t.Fatalf("diagnostics = %#v, want one with suggestion %q", diagnostics, test.want) + } + }) + } +} + +func TestDocsScriptPresentationDecisionListCountsULAndOL(t *testing.T) { + decision, err := parseDocsScriptPresentationDecision(`{ + "audience":"reader", + "reader_task":"review recommendations", + "genre_contract":"none", + "adapter":null, + "presentation_mode":"normal", + "visual_plan":{ + "reason":"group recommendations", + "blocks":[{"type":"list","min_count":2,"purpose":"separate equipment and food recommendations"}] + } + }`) + if err != nil { + t.Fatalf("parse list Presentation Decision: %v", err) + } + profile := docsScriptPublicProfile{Blocks: []docxparse.BlockShare{ + {Type: "ul", Count: 1}, + {Type: "ol", Count: 1}, + }} + if diagnostics := docsScriptPresentationDiagnostics(profile, decision); len(diagnostics) != 0 { + t.Fatalf("diagnostics = %#v, want ul + ol to satisfy two list blocks", diagnostics) + } + + decision.VisualPlan.Blocks[0].MinCount = 3 + diagnostics := docsScriptPresentationDiagnostics(profile, decision) + if len(diagnostics) != 1 || diagnostics[0].Code != docsScriptCodeRequiredBlock || + diagnostics[0].Expected == nil || diagnostics[0].Expected.Type != docsScriptListBlockType || + diagnostics[0].Expected.MinCount != 3 || diagnostics[0].Actual == nil || *diagnostics[0].Actual != 2 { + t.Fatalf("diagnostics = %#v, want list min_count 3 and actual 2", diagnostics) + } +} + +func TestDocsScriptOmitsDiagnosticsWhenPresentationDecisionPasses(t *testing.T) { + workDir := t.TempDir() + withDocsWorkingDir(t, workDir) + if err := os.WriteFile("flow.mmd", []byte("flowchart LR\nA --> B"), 0o600); err != nil { + t.Fatalf("write whiteboard source: %v", err) + } + if err := os.WriteFile("widget.html", []byte("status"), 0o600); err != nil { + t.Fatalf("write HTML source: %v", err) + } + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-presentation-pass")) + decision := `{ + "audience": "reader", + "reader_task": "understand the result and flow", + "genre_contract": "none", + "adapter": null, + "presentation_mode": "rich", + "word_count": {"min": 9, "max": 11}, + "visual_plan": { + "reason": "visual explanation", + "blocks": [ + {"type":"img","min_count":1,"purpose":"show the result"}, + {"type":"whiteboard","min_count":1,"purpose":"show the flow"}, + {"type":"html5-block","min_count":1,"purpose":"make the state explorable"} + ] + } + }` + + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptParse, + "--content", `标题

一个苹果是 an apple。

`, + "--presentation-decision", decision, + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("execute docs +script: %v", err) + } + + 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 envelope.Data["diagnostics"] != nil { + t.Fatalf("passing decision should omit data.diagnostics: %s", stdout) + } + var assessment docsScriptAssessment + if err := json.Unmarshal(envelope.Data["assessment"], &assessment); err != nil || assessment.Status != docsScriptAssessmentPassed { + t.Fatalf("passing decision data.assessment = %s, decode error = %v", envelope.Data["assessment"], err) + } +} + +func TestDocsScriptRejectsInvalidPresentationDecision(t *testing.T) { + validPrefix := `{"audience":"reader","reader_task":"understand the document","genre_contract":"none","adapter":null,"presentation_mode":"normal",` + tests := []struct { + name string + decision string + }{ + {name: "invalid json", decision: `{`}, + {name: "unknown field", decision: validPrefix + `"visual_plan":{"reason":"plain is enough","blocks":[],"image_enabled":true}}`}, + {name: "legacy fields", decision: `{"target":"reader","genre_contract":"none","adapter":"none","presentation_mode":"normal","hard_rule":"","word_count":20,"visual_plan":{"reason":"plain is enough","exception":"","blocks":[]}}`}, + {name: "integer word count", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","word_count":20,"visual_plan":{"reason":"plain is enough","blocks":[]}}`}, + {name: "missing word count bound", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","word_count":{"min":10},"visual_plan":{"reason":"plain is enough","blocks":[]}}`}, + {name: "empty word count range", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","word_count":{"min":null,"max":null},"visual_plan":{"reason":"plain is enough","blocks":[]}}`}, + {name: "non-positive word count", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","word_count":{"min":0,"max":10},"visual_plan":{"reason":"plain is enough","blocks":[]}}`}, + {name: "reversed word count range", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","word_count":{"min":20,"max":10},"visual_plan":{"reason":"plain is enough","blocks":[]}}`}, + {name: "missing required field", decision: `{"reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain is enough","blocks":[]}}`}, + {name: "missing adapter", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","presentation_mode":"normal","visual_plan":{"reason":"plain is enough","blocks":[]}}`}, + {name: "empty genre contract", decision: `{"audience":"reader","reader_task":"understand","genre_contract":" ","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain is enough","blocks":[]}}`}, + {name: "empty adapter", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":" ","presentation_mode":"normal","visual_plan":{"reason":"plain is enough","blocks":[]}}`}, + {name: "removed hard_rules field", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","hard_rules":[],"visual_plan":{"reason":"plain is enough","blocks":[]}}`}, + {name: "invalid presentation mode", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"decorative","visual_plan":{"reason":"visual","blocks":[]}}`}, + {name: "null block plan", decision: validPrefix + `"visual_plan":{"reason":"visual","blocks":null}}`}, + {name: "unknown block type", decision: validPrefix + `"visual_plan":{"reason":"visual","blocks":[{"type":"future-widget","min_count":1,"purpose":"show the result"}]}}`}, + {name: "ordinary text block is not presentation block", decision: validPrefix + `"visual_plan":{"reason":"visual","blocks":[{"type":"p","min_count":2,"purpose":"fill the quota"}]}}`}, + {name: "non-positive block minimum", decision: validPrefix + `"visual_plan":{"reason":"visual","blocks":[{"type":"img","min_count":0,"purpose":"show the result"}]}}`}, + {name: "missing block purpose", decision: validPrefix + `"visual_plan":{"reason":"visual","blocks":[{"type":"img","min_count":1,"purpose":""}]}}`}, + {name: "duplicate block type", decision: validPrefix + `"visual_plan":{"reason":"visual","blocks":[{"type":"img","min_count":1,"purpose":"one"},{"type":"img","min_count":1,"purpose":"two"}]}}`}, + {name: "multiple values", decision: `{} {}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-presentation-invalid")) + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptParse, + "--content", `

text

`, + "--presentation-decision", test.decision, + "--as", "bot", + }, f, nil) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--presentation-decision") + }) + } +} + +func TestDocsScriptPresentationDecisionAllowsNoneOrNullRoutes(t *testing.T) { + tests := []struct { + name string + genreContract string + adapter string + wantGenreNil bool + wantAdapterNil bool + }{ + {name: "both none", genreContract: `"none"`, adapter: `"none"`}, + {name: "both null", genreContract: `null`, adapter: `null`, wantGenreNil: true, wantAdapterNil: true}, + {name: "null genre", genreContract: `null`, adapter: `"none"`, wantGenreNil: true}, + {name: "null adapter", genreContract: `"none"`, adapter: `null`, wantAdapterNil: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + decision, err := parseDocsScriptPresentationDecision(fmt.Sprintf( + `{"audience":"reader","reader_task":"understand","genre_contract":%s,"adapter":%s,"presentation_mode":"normal","visual_plan":{"reason":"plain is enough","blocks":[]}}`, + test.genreContract, + test.adapter, + )) + if err != nil { + t.Fatalf("parseDocsScriptPresentationDecision() error = %v", err) + } + if got := decision.GenreContract == nil; got != test.wantGenreNil { + t.Fatalf("GenreContract nil = %v, want %v", got, test.wantGenreNil) + } + if got := decision.Adapter == nil; got != test.wantAdapterNil { + t.Fatalf("Adapter nil = %v, want %v", got, test.wantAdapterNil) + } + }) + } +} + +func TestDocsScriptPresentationDecisionRejectsNullWordCountWithOmitGuidance(t *testing.T) { + _, err := parseDocsScriptPresentationDecision(`{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","word_count":null,"visual_plan":{"reason":"plain text is sufficient","blocks":[]}}`) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--presentation-decision") + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error does not expose a typed problem: %v", err) + } + if got, want := problem.Message, "--presentation-decision word_count must be omitted when no word-count requirement was requested"; got != want { + t.Fatalf("message = %q, want %q", got, want) + } + if got, want := problem.Hint, "remove the word_count field; include it only when the user requested a word-count bound"; got != want { + t.Fatalf("hint = %q, want %q", got, want) + } +} + +func TestDocsScriptRichPresentationDecisionAllowsAnyBlockCount(t *testing.T) { + tests := []struct { + name string + decision string + }{ + { + name: "no planned blocks", + decision: `{"audience":"reader","reader_task":"understand the topic","genre_contract":"knowledge","adapter":null,"presentation_mode":"rich","visual_plan":{"reason":"the scan found no presentation block that would improve the reader task","blocks":[]}}`, + }, + { + name: "one planned block", + decision: `{"audience":"reader","reader_task":"understand the relationship","genre_contract":"knowledge","adapter":null,"presentation_mode":"rich","visual_plan":{"reason":"one diagram is sufficient","blocks":[{"type":"whiteboard","min_count":1,"purpose":"show the relationship"}]}}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := parseDocsScriptPresentationDecision(test.decision); err != nil { + t.Fatalf("parseDocsScriptPresentationDecision() error: %v", err) + } + }) + } +} + +func TestDocsScriptPresentationDecisionRejectsRemovedHardRulesField(t *testing.T) { + _, err := parseDocsScriptPresentationDecision(`{ + "audience":"executives", + "reader_task":"approve or reject the proposal", + "genre_contract":"formal-doc", + "adapter":null, + "presentation_mode":"formal", + "hard_rules":["do not use callout blocks","include a risks section"], + "visual_plan":{ + "reason":"a restrained structure best supports formal approval", + "blocks":[] + } + }`) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--presentation-decision") + problem, ok := errs.ProblemOf(err) + if !ok || !strings.Contains(problem.Message, `unknown field "hard_rules"`) { + t.Fatalf("error = %v, want removed hard_rules field to be rejected", err) + } +} + +func TestDocsScriptPresentationBlockPlanUsesProfileCatalogGenerically(t *testing.T) { + decision, err := parseDocsScriptPresentationDecision(`{ + "audience":"reviewers", + "reader_task":"compare both cohorts", + "genre_contract":"report", + "adapter":null, + "presentation_mode":"normal", + "visual_plan":{ + "reason":"compare exact fields", + "blocks":[{"type":"table","min_count":2,"purpose":"compare both cohorts"}] + } + }`) + if err != nil { + t.Fatalf("parseDocsScriptPresentationDecision() error: %v", err) + } + diagnostics := docsScriptPresentationDiagnostics(docsScriptPublicProfile{Blocks: []docxparse.BlockShare{{Type: "table", Count: 1}}}, decision) + if len(diagnostics) != 1 || diagnostics[0].Code != docsScriptCodeRequiredBlock || + diagnostics[0].Expected == nil || diagnostics[0].Expected.Type != "table" || diagnostics[0].Expected.MinCount != 2 || + diagnostics[0].Actual == nil || *diagnostics[0].Actual != 1 { + t.Fatalf("diagnostics = %#v", diagnostics) + } +} + +func TestDocsScriptRejectsMarkdownContent(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-reject-markdown")) + + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptParse, + "--content", "# 标题\n\n- item", + "--as", "bot", + }, f, stdout) + problem, ok := errs.ProblemOf(err) + var validationErr *errs.ValidationError + if !ok || problem.Subtype != errs.SubtypeInvalidArgument || + !errors.As(err, &validationErr) || validationErr.Param != "--content" { + t.Fatalf("error = %T %v, problem = %#v, validation = %#v", err, err, problem, validationErr) + } +} + +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": `在线文档

Hello world

`, + }, + }) + + 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": `

从 Wiki URL 读取

`, + }, + }) + + 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", `

local

`, + "--doc", "doxcnScriptConflict", + "--as", "bot", + }, f, nil) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--content", "--doc") +} + +func TestDocsScriptInitDraftCreatesUniqueWorkspacesWithoutXML(t *testing.T) { + workDir := t.TempDir() + withDocsWorkingDir(t, workDir) + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-unique-drafts")) + decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain text is sufficient","blocks":[]}}` + + initialize := func() docsScriptDraftResult { + t.Helper() + stdout.Reset() + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptInitDraft, + "--presentation-decision", decision, + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("execute docs +script: %v", err) + } + var envelope struct { + Data docsScriptDraftResult `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode stdout: %v\n%s", err, stdout) + } + return envelope.Data + } + + first := initialize() + second := initialize() + if first.DraftPath == second.DraftPath { + t.Fatalf("draft paths are identical: %q", first.DraftPath) + } + for _, got := range []docsScriptDraftResult{first, second} { + directory := filepath.Dir(got.DraftPath) + randomPart := strings.TrimSuffix(strings.TrimPrefix(directory, docsScriptDraftDirectoryPrefix), docsScriptDraftDirectorySuffix) + if got.Workspace != directory || got.Tip != docsScriptDraftTip || filepath.Base(got.DraftPath) != docsScriptDraftXMLFileName || filepath.Base(directory) != directory || + !strings.HasPrefix(directory, docsScriptDraftDirectoryPrefix) || !strings.HasSuffix(directory, docsScriptDraftDirectorySuffix) { + t.Fatalf("result = %+v, want draft__folder/draft.xml with an uncreated draft", got) + } + if len(randomPart) != docsScriptDraftRandomHexLength { + t.Fatalf("workspace random suffix = %q, want %d hex characters", randomPart, docsScriptDraftRandomHexLength) + } + if _, err := os.Stat(got.DraftPath); !os.IsNotExist(err) { + t.Fatalf("reserved draft XML path already exists: %q, err=%v", got.DraftPath, err) + } + savedDecision, err := os.ReadFile(filepath.Join(directory, docsScriptDecisionFile)) + if err != nil { + t.Fatalf("read saved decision for %q: %v", got.DraftPath, err) + } + if string(savedDecision) != decision { + t.Fatalf("saved decision = %q, want %q", savedDecision, decision) + } + } +} + +type docsScriptFailingFileIO struct { + fileio.WorkspaceFileIO + failSaveAt int + saveCalls int + removeCalls []string +} + +func (f *docsScriptFailingFileIO) Save(path string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) { + f.saveCalls++ + if f.saveCalls == f.failSaveAt { + return nil, errors.New("injected draft workspace save failure") + } + return f.WorkspaceFileIO.Save(path, opts, body) +} + +func (f *docsScriptFailingFileIO) RemoveWorkspaceEntry(path string) error { + f.removeCalls = append(f.removeCalls, path) + return f.WorkspaceFileIO.RemoveWorkspaceEntry(path) +} + +type docsScriptFileIOProvider struct { + fileIO fileio.FileIO +} + +func (p docsScriptFileIOProvider) Name() string { return "docs-script-test" } + +func (p docsScriptFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO { return p.fileIO } + +type docsScriptErrorWriter struct{} + +func (docsScriptErrorWriter) Write([]byte) (int, error) { + return 0, errors.New("injected output failure") +} + +func TestDocsScriptRemovesOnlyCurrentWorkspaceOnFinalFailure(t *testing.T) { + workDir := t.TempDir() + withDocsWorkingDir(t, workDir) + f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-cleanup-failed-workspace")) + baseFileIO, ok := f.ResolveFileIO(context.Background()).(fileio.WorkspaceFileIO) + if !ok { + t.Fatalf("default FileIO does not implement fileio.WorkspaceFileIO") + } + failingFileIO := &docsScriptFailingFileIO{ + WorkspaceFileIO: baseFileIO, + failSaveAt: 1, + } + f.FileIOProvider = docsScriptFileIOProvider{fileIO: failingFileIO} + decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain text is sufficient","blocks":[]}}` + + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptInitDraft, + "--presentation-decision", decision, + }, f, nil) + if err == nil { + t.Fatal("execute docs +script succeeded, want injected save failure") + } + entries, readErr := os.ReadDir(workDir) + if readErr != nil { + t.Fatalf("read work directory: %v", readErr) + } + if len(entries) != 0 { + t.Fatalf("failed initialization left workspace entries: %+v", entries) + } + + f.IOStreams.Out = docsScriptErrorWriter{} + err = mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptInitDraft, + "--presentation-decision", decision, + }, f, nil) + if err == nil { + t.Fatal("execute docs +script succeeded, want injected output failure") + } + entries, readErr = os.ReadDir(workDir) + if readErr != nil { + t.Fatalf("read work directory after output failure: %v", readErr) + } + if len(entries) != 0 { + t.Fatalf("output failure left workspace entries: %+v", entries) + } + + if err := os.MkdirAll("unrelated_folder", 0o700); err != nil { + t.Fatalf("create unrelated directory: %v", err) + } + if err := os.WriteFile(filepath.Join("unrelated_folder", docsScriptDraftXMLFileName), []byte("keep"), 0o600); err != nil { + t.Fatalf("create unrelated file: %v", err) + } + unrelatedPath := filepath.Join("unrelated_folder", docsScriptDraftXMLFileName) + removeCallsBefore := len(failingFileIO.removeCalls) + if err := (docsScriptWorkspace{ + path: unrelatedPath, + fileIO: failingFileIO, + }).remove(); err == nil { + t.Fatal("workspace cleanup accepted an unrelated directory") + } + if len(failingFileIO.removeCalls) != removeCallsBefore { + t.Fatalf("unsafe workspace reached FileIO removal: %v", failingFileIO.removeCalls[removeCallsBefore:]) + } + if _, err := os.Stat(filepath.Join("unrelated_folder", docsScriptDraftXMLFileName)); err != nil { + t.Fatalf("unrelated file was removed: %v", err) + } + if len(failingFileIO.removeCalls) != 4 { + t.Fatalf("workspace FileIO removal calls = %v, want two entries for each failed initialization", failingFileIO.removeCalls) + } + for i := 0; i < len(failingFileIO.removeCalls); i += 2 { + decisionPath := failingFileIO.removeCalls[i] + directory := failingFileIO.removeCalls[i+1] + if filepath.Base(decisionPath) != docsScriptDecisionFile || filepath.Dir(decisionPath) != directory { + t.Fatalf("workspace cleanup pair = %q, %q", decisionPath, directory) + } + if !isDocsScriptWorkspacePath(filepath.Join(directory, docsScriptDraftXMLFileName)) { + t.Fatalf("workspace cleanup used unexpected directory %q", directory) + } + } +} + +type docsScriptBasicFileIO struct { + fileio.FileIO +} + +func TestDocsScriptInitDraftRequiresWorkspaceFileIO(t *testing.T) { + workDir := t.TempDir() + withDocsWorkingDir(t, workDir) + f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-workspace-fileio")) + f.FileIOProvider = docsScriptFileIOProvider{fileIO: docsScriptBasicFileIO{ + FileIO: f.ResolveFileIO(context.Background()), + }} + decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain text is sufficient","blocks":[]}}` + + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptInitDraft, + "--presentation-decision", decision, + }, f, nil) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("problem = %+v, ok=%v, want validation/failed_precondition", problem, ok) + } + entries, readErr := os.ReadDir(workDir) + if readErr != nil { + t.Fatalf("read work directory: %v", readErr) + } + if len(entries) != 0 { + t.Fatalf("unsupported FileIO created workspace entries: %+v", entries) + } +} + +func TestDocsScriptInitDraftRejectsContentAndDoc(t *testing.T) { + decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain text is sufficient","blocks":[]}}` + tests := []struct { + name string + args []string + param string + }{ + {name: "content", args: []string{"--content", "

text

"}, param: "--content"}, + {name: "doc", args: []string{"--doc", "doxcnScriptTemp"}, param: "--doc"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-init-draft-flags")) + args := []string{"+script", "--command", docsScriptInitDraft, "--presentation-decision", decision, "--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 TestDocsScriptDryRunHasNoAPICall(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-dry-run")) + + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptParse, + "--content", `

text

`, + "--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"` + } + decodeDocsScriptDryRun(t, stdout, &got) + if len(got.API) != 0 || got.Command != docsScriptParse || got.Network { + t.Fatalf("dry-run output = %+v", got) + } + var envelope struct { + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode dry-run envelope: %v", err) + } + if _, exists := envelope.Data["strict"]; exists { + t.Fatalf("dry-run output still exposes removed strict mode: %s", stdout) + } +} + +func TestDocsScriptRemoteImagePreflightDryRunDeclaresNetwork(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-remote-preflight-dry-run")) + decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"an image provides visual evidence","blocks":[{"type":"img","min_count":1,"purpose":"show visual evidence"}]}}` + + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptParse, + "--content", `Draft`, + "--presentation-decision", decision, + "--dry-run", + }, f, stdout) + if err != nil { + t.Fatalf("execute docs +script dry-run: %v", err) + } + var got struct { + Command string `json:"command"` + Network bool `json:"network"` + } + decodeDocsScriptDryRun(t, stdout, &got) + if got.Command != docsScriptParse || !got.Network { + t.Fatalf("dry-run output = %+v, want network=true for remote image preflight", got) + } +} + +func TestDocsScriptDryRunRejectsInvalidSavedPresentationDecision(t *testing.T) { + workDir := t.TempDir() + withDocsWorkingDir(t, workDir) + workspace := filepath.Join("draft_workspace") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatalf("create draft workspace: %v", err) + } + if err := os.WriteFile(filepath.Join(workspace, "draft.xml"), []byte(`

draft

`), 0o600); err != nil { + t.Fatalf("write draft XML: %v", err) + } + if err := os.WriteFile(filepath.Join(workspace, docsScriptDecisionFile), []byte(`{"invalid":true}`), 0o600); err != nil { + t.Fatalf("write invalid saved Presentation Decision: %v", err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-invalid-sidecar-dry-run")) + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptParse, + "--content", "@" + filepath.Join(workspace, "draft.xml"), + "--dry-run", + }, f, stdout) + if err == nil { + t.Fatal("execute docs +script dry-run succeeded with an invalid saved Presentation Decision") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Subtype != errs.SubtypeUnknown { + t.Fatalf("error = %T %v, problem = %#v; want typed invalid saved decision error", err, err, problem) + } +} + +func TestDocsScriptInitDraftDryRunDoesNotWrite(t *testing.T) { + workDir := t.TempDir() + withDocsWorkingDir(t, workDir) + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-init-draft-dry-run")) + + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptInitDraft, + "--presentation-decision", `{"audience":"reader","reader_task":"understand key states","genre_contract":"none","adapter":null,"presentation_mode":"rich","visual_plan":{"reason":"visual explanation","blocks":[{"type":"img","min_count":2,"purpose":"show key states"}]}}`, + "--dry-run", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("execute init-draft dry-run: %v", err) + } + var got struct { + API []any `json:"api"` + Command string `json:"command"` + PresentationDecision bool `json:"presentation_decision"` + CreatesWorkspace bool `json:"creates_workspace"` + CreatesDraftFile bool `json:"creates_draft_file"` + DirectoryPattern string `json:"directory_pattern"` + XMLFileName string `json:"xml_file_name"` + Network bool `json:"network"` + } + decodeDocsScriptDryRun(t, stdout, &got) + if len(got.API) != 0 || got.Command != docsScriptInitDraft || !got.PresentationDecision || + !got.CreatesWorkspace || got.CreatesDraftFile || got.DirectoryPattern != docsScriptDraftDirectoryPattern || + got.XMLFileName != docsScriptDraftXMLFileName || 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"` + } + decodeDocsScriptDryRun(t, stdout, &got) + 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 decodeDocsScriptDryRun(t *testing.T, stdout *bytes.Buffer, dst interface{}) { + t.Helper() + var envelope struct { + OK bool `json:"ok"` + DryRun bool `json:"dry_run"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode dry-run envelope: %v\n%s", err, stdout) + } + if !envelope.OK || !envelope.DryRun { + t.Fatalf("unexpected dry-run envelope: %s", stdout) + } + if err := json.Unmarshal(envelope.Data, dst); err != nil { + t.Fatalf("decode dry-run data: %v\n%s", err, stdout) + } +} + +func TestDocsScriptReturnsTypedParseError(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-error")) + + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptParse, + "--content", `

text

`, + "--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 TestDocsScriptProfilesCompatibleMalformedXML(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-malformed")) + + err := mountAndRunDocs(t, DocsScript, []string{ + "+script", + "--command", docsScriptParse, + "--content", `T
  • one
  • two

tailLocal image`, + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("execute parse: %v", err) + } + + var envelope struct { + Data struct { + Profile docsScriptPublicProfile `json:"profile"` + } `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 || blockCount(envelope.Data.Profile.Blocks, "img") != 1 { + t.Fatalf("profile = %+v, want one title and one img block", envelope.Data.Profile) + } +} + +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 init-draft --presentation-decision`, `--content "@./draft.xml"`} { + if !strings.Contains(cmd.Example, want) { + t.Errorf("help examples missing %q: %q", want, cmd.Example) + } + } + if strings.Contains(cmd.Example, `@./draft.md`) { + t.Fatalf("help examples contain unsupported Markdown input: %q", cmd.Example) + } +} + +func blockCount(blocks []docxparse.BlockShare, typ string) int { + for _, block := range blocks { + if block.Type == typ { + return block.Count + } + } + return 0 +} diff --git a/shortcuts/doc/docs_update.go b/shortcuts/doc/docs_update.go index 9238924b1c..7c69f726b2 100644 --- a/shortcuts/doc/docs_update.go +++ b/shortcuts/doc/docs_update.go @@ -14,13 +14,30 @@ func v1UpdateFlags() []common.Flag { return docsLegacyFlagDefinitions(docsUpdateLegacyFlags()) } +var docsUpdateLocalResourceScopes = []string{ + "docs:document.media:upload", +} + +var docsUpdateWikiLocalResourceScopes = []string{ + "docs:document.media:upload", + "wiki:node:retrieve", +} + +func docsUpdateLocalResourceScopesFor(ref documentRef) []string { + if ref.Kind == "wiki" { + return docsUpdateWikiLocalResourceScopes + } + return docsUpdateLocalResourceScopes +} + var DocsUpdate = common.Shortcut{ - Service: "docs", - Command: "+update", - Description: "Update a Lark document", - Risk: "write", - Scopes: []string{"docx:document:write_only", "docx:document:readonly"}, - AuthTypes: []string{"user", "bot"}, + Service: "docs", + Command: "+update", + Description: "Update a Lark document", + Risk: "write", + Scopes: []string{"docx:document:write_only", "docx:document:readonly"}, + ConditionalScopes: docsUpdateWikiLocalResourceScopes, + AuthTypes: []string{"user", "bot"}, Flags: concatFlags( []common.Flag{ docsAPIVersionCompatFlag(), diff --git a/shortcuts/doc/docs_update_test.go b/shortcuts/doc/docs_update_test.go index aa6b5ea0dd..4e173e3666 100644 --- a/shortcuts/doc/docs_update_test.go +++ b/shortcuts/doc/docs_update_test.go @@ -124,6 +124,53 @@ func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) { } } +func TestDocsUpdateV2BlockIDFlagDocumentsServiceSentinels(t *testing.T) { + t.Parallel() + + var desc string + for _, candidate := range v2UpdateFlags() { + if candidate.Name == "block-id" { + desc = candidate.Desc + break + } + } + if desc == "" { + t.Fatal("block-id flag not found") + } + for _, want := range []string{ + "passed through to the service", + "-1 means document end", + "0 means document start", + } { + if !strings.Contains(desc, want) { + t.Fatalf("block-id help = %q, want it to contain %q", desc, want) + } + } +} + +func TestDocsUpdateV2CommandFlagDocumentsStrReplaceLimits(t *testing.T) { + t.Parallel() + + var desc string + for _, candidate := range v2UpdateFlags() { + if candidate.Name == "command" { + desc = candidate.Desc + break + } + } + if desc == "" { + t.Fatal("command flag not found") + } + for _, want := range []string{ + "str_replace does not support resource replacement", + "prefer block_replace when multiple blocks are involved", + } { + if !strings.Contains(desc, want) { + t.Fatalf("command help = %q, want it to contain %q", desc, want) + } + } +} + func TestBuildUpdateBodyIncludesReferenceMap(t *testing.T) { t.Parallel() diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go index 1607a428b5..45da1f4740 100644 --- a/shortcuts/doc/docs_update_v2.go +++ b/shortcuts/doc/docs_update_v2.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -28,15 +29,19 @@ const docsReferenceMapFlagDesc = "Structured `reference_map` JSON object; must b const docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc +const docsUpdateBlockIDFlagDesc = "target block ID(s) for block operations (comma-separated for batch delete); sentinel values are passed through to the service: -1 means document end where supported, 0 means document start where supported" + +const docsUpdateCommandFlagDesc = "operation; str_replace does not support resource replacement; prefer block_replace when multiple blocks are involved; requirements: str_replace(--pattern), block_delete(--block-id, comma-separated for batch), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)" + // v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path. func v2UpdateFlags() []common.Flag { return []common.Flag{ - {Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id, comma-separated for batch), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()}, + {Name: "command", Desc: docsUpdateCommandFlagDesc, Enum: validCommandsV2Keys()}, {Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}}, {Name: "content", Desc: docsUpdateContentFlagBase, Input: []string{common.File, common.Stdin}}, {Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}}, - {Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"}, - {Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"}, + {Name: "pattern", Desc: "simple inline text matched by str_replace; use block_replace for paragraphs, multiline content, or multiple blocks"}, + {Name: "block-id", Desc: docsUpdateBlockIDFlagDesc}, {Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"}, {Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"}, } @@ -50,7 +55,8 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { if err := validateDocsV2Only(runtime, "+update", docsUpdateLegacyFlags()); err != nil { return err } - if _, err := parseDocumentRef(runtime.Str("doc")); err != nil { + docRef, err := parseDocumentRef(runtime.Str("doc")) + if err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc") } cmd := runtime.Str("command") @@ -118,8 +124,21 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { } } if content != "" { - _, err := resolveDocsV2ContentReferenceMap(runtime) - return err + input, err := resolveDocsV2ContentReferenceMap(runtime) + if err != nil { + return err + } + if len(input.LocalResources) > 0 { + if err := validateLocalDocResourceUpdateCommand(cmd, input.LocalResources); err != nil { + return err + } + if docRef.Kind == "doc" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "local images and files require a docx token/URL or a wiki URL that resolves to docx"). + WithParam("--doc") + } + return runtime.EnsureScopes(docsUpdateLocalResourceScopesFor(docRef)) + } } return nil } @@ -127,32 +146,57 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { // Validate has already accepted --doc; parseDocumentRef cannot fail here. ref, _ := parseDocumentRef(runtime.Str("doc")) - body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime) + body, resources, err := buildUpdateBodyWithPreparedInput(runtime) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } - apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token) - return common.NewDryRunAPI(). - PUT(apiPath). + documentID := ref.Token + dry := common.NewDryRunAPI() + if len(resources) > 0 && ref.Kind == "wiki" { + documentID = "" + dry.GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("Resolve wiki node to its docx document before writing local resources"). + Params(map[string]interface{}{"token": ref.Token}) + } + apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", validate.EncodePathSegment(documentID)) + dry.PUT(apiPath). Desc("OpenAPI: update document"). Body(body). - Set("document_id", ref.Token) + Set("document_id", documentID) + dry = appendRemoteDocImageDownloadsDryRun(dry, resources) + return appendLocalDocResourcesDryRun(dry, documentID, resources) } func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { ref, _ := parseDocumentRef(runtime.Str("doc")) - apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token) - body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime) + body, resources, err := buildUpdateBodyWithPreparedInput(runtime) if err != nil { return err } + if err := validateRemoteDocImageSources(runtime.Ctx(), resources); err != nil { + return err + } + documentID := ref.Token + if len(resources) > 0 { + documentID, err = resolveDocxDocumentID(runtime, runtime.Str("doc")) + if err != nil { + return err + } + } + apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", validate.EncodePathSegment(documentID)) data, err := doDocAPI(runtime, "PUT", apiPath, body) if err != nil { return err } + if docsAPIOperationFailed(data) { + return runtime.OutPartialFailure(data, nil) + } + if err := finalizeLocalDocResources(runtime, documentID, data, resources); err != nil { + return err + } runtime.OutRaw(data, nil) return nil } diff --git a/shortcuts/doc/helpers.go b/shortcuts/doc/helpers.go index 5c4ebfb3c1..789915af58 100644 --- a/shortcuts/doc/helpers.go +++ b/shortcuts/doc/helpers.go @@ -76,7 +76,18 @@ func extractDocumentFragment(raw string) string { // CallAPITyped lifts the x-tt-logid response header onto the typed error so log_id // surfaces for support escalations even when the body omits it. func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body interface{}) (map[string]interface{}, error) { - return runtime.CallAPITyped(method, apiPath, nil, body) + data, err := runtime.CallAPITyped(method, apiPath, nil, body) + if err != nil { + return data, err + } + if data == nil { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "document API returned an empty data object") + } + return data, nil +} + +func docsAPIOperationFailed(data map[string]interface{}) bool { + return strings.EqualFold(strings.TrimSpace(common.GetString(data, "result")), "failed") } func docsSceneFromContext(ctx context.Context) string { diff --git a/shortcuts/doc/html5_block_resources.go b/shortcuts/doc/html5_block_resources.go index 21fbc01ee6..4ac884b839 100644 --- a/shortcuts/doc/html5_block_resources.go +++ b/shortcuts/doc/html5_block_resources.go @@ -49,8 +49,9 @@ type html5BlockReferenceEntry struct { type html5BlockReferenceMap map[string]map[string]html5BlockReferenceEntry type docsV2WriteInput struct { - Content string - ReferenceMap map[string]interface{} + Content string + ReferenceMap map[string]interface{} + LocalResources []localDocResource } type html5BlockAttr struct { @@ -68,27 +69,35 @@ type whiteboardStartTag struct { SelfClosing bool } -func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) { +func buildCreateBodyWithPreparedInput(runtime *common.RuntimeContext) (map[string]interface{}, []localDocResource, error) { body := buildCreateBody(runtime) if runtime.Str("content") == "" && !runtime.Changed("reference-map") { - return body, nil + return body, nil, nil } input, err := resolveDocsV2ContentReferenceMap(runtime) if err != nil { - return nil, err + return nil, nil, err } body["content"] = buildCreateContentWithBody(runtime, input.Content) if len(input.ReferenceMap) > 0 { body["reference_map"] = input.ReferenceMap } - return body, nil + return body, input.LocalResources, nil } func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) { + body, _, err := buildUpdateBodyWithPreparedInput(runtime) + return body, err +} + +func buildUpdateBodyWithPreparedInput(runtime *common.RuntimeContext) (map[string]interface{}, []localDocResource, error) { body := buildUpdateBody(runtime) input, err := resolveDocsV2ContentReferenceMap(runtime) if err != nil { - return nil, err + return nil, nil, err + } + if err := validateLocalDocResourceUpdateCommand(runtime.Str("command"), input.LocalResources); err != nil { + return nil, nil, err } if input.Content != "" { body["content"] = input.Content @@ -96,7 +105,7 @@ func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[s if len(input.ReferenceMap) > 0 { body["reference_map"] = input.ReferenceMap } - return body, nil + return body, input.LocalResources, nil } func validateDocsV2ReferenceMapFlags(runtime *common.RuntimeContext) error { @@ -119,17 +128,25 @@ func resolveDocsV2ContentReferenceMap(runtime *common.RuntimeContext) (docsV2Wri } func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteInput) (docsV2WriteInput, error) { + return prepareDocsV2WriteInputForFormat(runtime, runtime.Str("doc-format"), input) +} + +func prepareDocsV2WriteInputForFormat(runtime *common.RuntimeContext, format string, input docsV2WriteInput) (docsV2WriteInput, error) { refMap := cloneReferenceMapObject(input.ReferenceMap) html5RefMap, err := html5ReferenceMapFromObject(refMap) if err != nil { return docsV2WriteInput{}, err } - content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content) + content, localResources, err := prepareLocalDocResources(runtime, format, input.Content) + if err != nil { + return docsV2WriteInput{}, err + } + content, err = prepareWhiteboardWriteContent(runtime, format, content) if err != nil { return docsV2WriteInput{}, err } - content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), content, html5RefMap) + content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, format, content, html5RefMap) if err != nil { return docsV2WriteInput{}, err } @@ -138,8 +155,9 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn } refMap = mergeHTML5ReferenceMap(refMap, html5RefMap) return docsV2WriteInput{ - Content: content, - ReferenceMap: refMap, + Content: content, + ReferenceMap: refMap, + LocalResources: localResources, }, nil } diff --git a/shortcuts/doc/internal/docxparse/block_catalog.go b/shortcuts/doc/internal/docxparse/block_catalog.go new file mode 100644 index 0000000000..5ceddd5d94 --- /dev/null +++ b/shortcuts/doc/internal/docxparse/block_catalog.go @@ -0,0 +1,116 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package docxparse + +type tagLayout string + +type tagDefinition struct { + layout tagLayout + presentation bool +} + +const ( + layoutBlock tagLayout = "block" + layoutInline tagLayout = "inline" + layoutDual tagLayout = "dual" + layoutStructural tagLayout = "structural" + layoutCommand tagLayout = "command" +) + +// blockCatalog is a profiling catalog, not an XML schema. Unknown elements +// remain valid XML containers and are intentionally absent from block counts. +var blockCatalog = map[string]tagDefinition{} + +func init() { + registerTags(layoutBlock, + "title", "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "h9", "p", + "div", "ul", "ol", "li", "blockquote", "column", "thead", "tbody", "tfoot", + "tr", "hr", "source", "base_refer", "synced_reference", "isv", "view", + "synced-source", "readonly-block", "checkbox", "okr-objective", "okr-key-result", + "okr-progress", "task", "append", + ) + registerPresentationTags(layoutBlock, + "grid", "table", "pre", "img", "bitable", "sheet", "mindnote", "whiteboard", + "html5-block", "figure", "callout", "chat_card", "okr", "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", "vc-tabs", "vc-summary-tab", "vc-transcribe-tab", + ) + 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 { + blockCatalog[tag] = tagDefinition{layout: layout} + } +} + +func registerPresentationTags(layout tagLayout, tags ...string) { + for _, tag := range tags { + blockCatalog[tag] = tagDefinition{layout: layout, presentation: true} + } +} + +func layoutOf(tag string) tagLayout { + return blockCatalog[tag].layout +} + +func isKnownTag(tag string) bool { _, ok := blockCatalog[tag]; return ok } + +// IsPresentationBlockType reports whether tag is both counted by the profile +// and suitable for a Presentation Decision block plan. The centralized +// catalog keeps planning policy independent from individual component fields. +func IsPresentationBlockType(tag string) bool { + definition, ok := blockCatalog[tag] + profiled := definition.layout == layoutBlock || definition.layout == layoutDual + return ok && profiled && definition.presentation +} + +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, "i": 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}, +} + +func shouldAutoClose(openTag, nextTag string) bool { + if strictPhrasingTags[openTag] && layoutOf(nextTag) == layoutBlock { + return true + } + return autoCloseTags[openTag] != nil && autoCloseTags[openTag][nextTag] +} diff --git a/shortcuts/doc/internal/docxparse/block_catalog_test.go b/shortcuts/doc/internal/docxparse/block_catalog_test.go new file mode 100644 index 0000000000..6b78c14fcb --- /dev/null +++ b/shortcuts/doc/internal/docxparse/block_catalog_test.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package docxparse + +import "testing" + +func TestIsPresentationBlockType(t *testing.T) { + tests := []struct { + tag string + want bool + }{ + {tag: "img", want: true}, + {tag: "whiteboard", want: true}, + {tag: "html5-block", want: true}, + {tag: "table", want: true}, + {tag: "pre", want: true}, + {tag: "p", want: false}, + {tag: "h1", want: false}, + {tag: "li", want: false}, + {tag: "code", want: false}, + {tag: "future-widget", want: false}, + } + + for _, test := range tests { + t.Run(test.tag, func(t *testing.T) { + if got := IsPresentationBlockType(test.tag); got != test.want { + t.Fatalf("IsPresentationBlockType(%q) = %v, want %v", test.tag, got, test.want) + } + }) + } +} diff --git a/shortcuts/doc/internal/docxparse/errors.go b/shortcuts/doc/internal/docxparse/errors.go new file mode 100644 index 0000000000..4a84140bd6 --- /dev/null +++ b/shortcuts/doc/internal/docxparse/errors.go @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package docxparse + +import "fmt" + +// parseError is an internal parser-domain error. Command boundaries attach the +// relevant flag name and convert it to the public errs.* contract. +type parseError struct { + message string +} + +func (e *parseError) Error() string { return e.message } + +func newParseError(format string, args ...any) error { + return &parseError{message: fmt.Sprintf(format, args...)} +} diff --git a/shortcuts/doc/internal/docxparse/model.go b/shortcuts/doc/internal/docxparse/model.go new file mode 100644 index 0000000000..3364ae8e5b --- /dev/null +++ b/shortcuts/doc/internal/docxparse/model.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package docxparse parses LarkOpenCLI DocxXML into a small DOM for the docs +// +script shortcut. +package docxparse + +// Format is an accepted source document format. +type Format string + +const ( + FormatXML Format = "xml" +) + +// 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) +} diff --git a/shortcuts/doc/internal/docxparse/parse_test.go b/shortcuts/doc/internal/docxparse/parse_test.go new file mode 100644 index 0000000000..70af352220 --- /dev/null +++ b/shortcuts/doc/internal/docxparse/parse_test.go @@ -0,0 +1,503 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package docxparse + +import ( + "regexp" + "strings" + "testing" +) + +func TestParseXMLBuildsBlockDistribution(t *testing.T) { + result, err := Parse(`T

P

  • A
  • B
`, FormatXML) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if result.XML != `T

P

  • A
  • B
` { + 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: "missing closing tag", source: `

one`}, + {name: "mismatched closing tag", source: `x`}, + {name: "closing void tag", source: ``}, + {name: "malformed block id", source: ``}, + {name: "unterminated cdata", source: ``}, + {name: "tag spacing", source: `< p>text< / p>`}, + {name: "self closing slash spacing", source: `

`}, + {name: "unquoted attribute", source: `

text

`}, + {name: "invalid entity", source: `

one &unknown;

`}, + {name: "invalid attribute entity", source: ``}, + {name: "bare attribute ampersand", source: ``}, + } + 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 TestParseXMLChecksSyntaxWithoutBusinessSchema(t *testing.T) { + source := `

known

` + + `x
nested
` + + `orphan` + 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) + } + for tag, want := range map[string]int{ + "p": 1, "table": 1, "tr": 1, "img": 1, "task": 1, "whiteboard": 1, + } { + if got := blockCountForTest(result.Profile.Blocks, tag); got != want { + t.Errorf("%s blocks = %d, want %d; profile=%+v", tag, got, want, result.Profile) + } + } + if result.Profile.BlockCount != 6 { + t.Fatalf("profile = %+v, want six known blocks", result.Profile) + } + for _, tag := range []string{"extension", "td"} { + if got := blockCountForTest(result.Profile.Blocks, tag); got != 0 { + t.Errorf("%s blocks = %d, want 0", tag, got) + } + } +} + +func TestParseCompatibleXMLRepairsMalformedXMLForProfile(t *testing.T) { + tests := []struct { + name string + source string + blocks map[string]int + total int + }{ + { + name: "missing closes and final bracket", + source: `标题

one

` + + `
  • A
  • B

尾声one< / p>`, + blocks: map[string]int{"p": 1, "img": 1}, + total: 2, + }, + { + name: "block interrupts inline nesting", + source: `x
y
`, + blocks: map[string]int{"table": 1, "tr": 1}, + total: 2, + }, + { + name: "truncated cdata keeps later blocks", + source: `

after

`, + blocks: map[string]int{"code": 1, "p": 1}, + total: 2, + }, + { + name: "legacy block id does not hide following content", + source: `

x

`, + blocks: map[string]int{"p": 1}, + total: 1, + }, + { + name: "orphan close is ignored", + source: `

x

`, + blocks: map[string]int{"h1": 1}, + total: 1, + }, + { + name: "unterminated comment resumes at later block", + source: `

one

?>` + if got := normalizeCompatibleXMLInput(source); got != source { + t.Fatalf("normalizeCompatibleXMLInput() = %q, want protected source unchanged %q", got, source) + } + profile, err := ParseCompatibleXML(source) + if err != nil { + t.Fatalf("ParseCompatibleXML() error = %v", err) + } + if profile.BlockCount != 1 || blockCountForTest(profile.Blocks, "p") != 1 { + t.Fatalf("profile = %+v, want one paragraph", profile) + } +} + +func TestCompatibleXMLNormalizationDoesNotRewriteAttributeText(t *testing.T) { + source := `

visible

` + if got := normalizeCompatibleXMLInput(source); got != source { + t.Fatalf("normalizeCompatibleXMLInput() = %q, want attribute source unchanged %q", got, source) + } +} + +func TestCompatibleBlockIDPatternsOnlyInspectCurrentToken(t *testing.T) { + source := `

x

` + for _, expression := range []*regexp.Regexp{ + compatibleBlockIDSelfClosing, + compatibleBlockIDWithClosing, + compatibleBlockIDOpen, + } { + if match := expression.FindStringIndex(source); match != nil { + t.Fatalf("legacy block_id expression scanned past the current token: match=%v", match) + } + } +} + +func TestParseCompatibleXMLAcceptsLocalImagePath(t *testing.T) { + profile, err := ParseCompatibleXML(`Local image`) + if err != nil { + t.Fatalf("ParseCompatibleXML() error = %v", err) + } + if profile.BlockCount != 2 || blockCountForTest(profile.Blocks, "img") != 1 { + t.Fatalf("profile = %+v, want one title and one img block", profile) + } +} + +func TestParseCompatibleXMLDoesNotSupportLegacyQAImage(t *testing.T) { + profile, err := ParseCompatibleXML(``) + if err != nil { + t.Fatalf("ParseCompatibleXML() error = %v", err) + } + if blockCountForTest(profile.Blocks, "img") != 0 { + t.Fatalf("profile = %+v, legacy qa_image must not be converted to img", profile) + } +} + +func TestParseCompatibleXMLCompatibilityKeepsGlobalSafetyErrorsFatal(t *testing.T) { + tests := []struct { + name string + source string + }{ + {name: "unsafe declaration", source: `

text

`}, + {name: "invalid utf8", source: string([]byte{'<', 'p', '>', 0xff, '<', '/', 'p', '>'})}, + {name: "XML control character", source: "

before\x0bafter

"}, + {name: "XML noncharacter", source: "

before\ufffeafter

"}, + {name: "excessive nesting", source: strings.Repeat("", MaxNestingDepth+1)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := ParseCompatibleXML(tt.source); err == nil { + t.Fatalf("ParseCompatibleXML(%q) succeeded, want safety error", tt.source) + } + }) + } +} + +func TestParseRejectsXML10ForbiddenCharactersInEveryTextLocation(t *testing.T) { + for _, source := range []string{ + "

before\x01after

", + "

text

", + "", + } { + if _, err := Parse(source, FormatXML); err == nil { + t.Errorf("Parse(%q) succeeded, want XML 1.0 character error", source) + } + } +} + +func TestParseCompatibleXMLDoesNotCountRawWhiteboardTagsAsBlocks(t *testing.T) { + profile, err := ParseCompatibleXML(`raw

visible

`) + if err != nil { + t.Fatalf("ParseCompatibleXML() error = %v", err) + } + if profile.BlockCount != 2 || blockCountForTest(profile.Blocks, "whiteboard") != 1 || blockCountForTest(profile.Blocks, "p") != 1 { + t.Fatalf("profile = %+v, want only whiteboard and p blocks", profile) + } + if blockCountForTest(profile.Blocks, "img") != 0 { + t.Fatalf("profile = %+v, raw whiteboard image must not be counted", profile) + } +} + +func TestParseXMLDoesNotNormalizeTagAliases(t *testing.T) { + source := `

onetwo

known

` + 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 || + blockCountForTest(result.Profile.Blocks, "p") != 1 || + blockCountForTest(result.Profile.Blocks, "img") != 1 { + t.Fatalf("profile = %+v, want only canonical p and img blocks", result.Profile) + } +} + +func TestParseXMLAcceptsArbitraryAttributesWithoutChangingInput(t *testing.T) { + source := `

x

` + 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 TestParseCompatibleXMLCompatibilityAcceptsBareAmpersandsInAttributes(t *testing.T) { + source := `-1` + profile, err := ParseCompatibleXML(source) + if err != nil { + t.Fatalf("ParseCompatibleXML() error = %v", err) + } + if profile.BlockCount != 1 || blockCountForTest(profile.Blocks, "img") != 1 { + t.Fatalf("profile = %+v, want one img block", profile) + } +} + +func TestParseXMLPreservesValidCDATA(t *testing.T) { + source := ` d]]>` + 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 TestParseXMLAllowsDeclarationTextInsideCDATAAndComments(t *testing.T) { + source := `

]]>

ok

` + 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 TestParseXMLPreservesWordBoundaryAcrossNewline(t *testing.T) { + result, err := Parse("

Hello\nworld

", FormatXML) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if result.XML != "

Hello\nworld

" { + t.Fatalf("XML = %q, want source unchanged", result.XML) + } + if result.Profile.WordCount != 2 || result.Profile.CharCount != 10 { + t.Fatalf("profile = %+v, want word_count=2 char_count=10", result.Profile) + } +} + +func TestParseXMLPreservesUTF8BOM(t *testing.T) { + source := "\uFEFF

text

" + 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 TestTextProfileMatchesLarkOpenCLIContract(t *testing.T) { + result, err := Parse(`标题

一个苹果是 an apple。

`, 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: `

Hello world 123.45。

`, + words: 4, chars: 17, blocks: 1, english: 2, numbers: 1, + }, + { + name: "list and checkbox markers", + source: `
  • two
完成`, + 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 TestTextProfileCountsNumericCodeLexeme(t *testing.T) { + result, err := Parse(`
123
`, FormatXML) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if result.Profile.WordCount != 1 || result.Profile.Breakdown.NumberWords != 1 || result.Profile.Breakdown.Digits != 3 { + t.Fatalf("profile = %+v, want one numeric code word", result.Profile) + } +} + +func TestTextProfileUsesVisibleAttributeFallbacks(t *testing.T) { + result, err := Parse(`

Click here

`, FormatXML) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + profile := result.Profile + if profile.WordCount != 5 || profile.CharCount != 20 { + t.Fatalf("profile = %+v, want word_count=5 char_count=20", profile) + } + if profile.Breakdown.EnglishWords != 4 || profile.Breakdown.HanChars != 1 { + t.Fatalf("breakdown = %+v", profile.Breakdown) + } +} + +func TestListMarkersUseMarkerSegments(t *testing.T) { + nodes, err := parseXML(`
  • one
  1. two
`) + if err != nil { + t.Fatalf("parseXML() error = %v", err) + } + markers := map[string]segmentKind{} + for _, segment := range extractSegments(nodes) { + if segment.text == "•" || segment.text == "1." { + markers[segment.text] = segment.kind + } + } + for _, marker := range []string{"•", "1."} { + if markers[marker] != segmentMarker { + t.Fatalf("marker %q kind = %v, want segmentMarker", marker, markers[marker]) + } + } +} + +func TestTextProfileHandlesLongASCIIWord(t *testing.T) { + word := strings.Repeat("a", 100_000) + result, err := Parse("

"+word+"

", FormatXML) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if result.Profile.WordCount != 1 || result.Profile.CharCount != len(word) { + t.Fatalf("profile = %+v", result.Profile) + } +} + +func TestParseRejectsUnsafeXMLDeclarations(t *testing.T) { + _, err := Parse(`]>

&x;

`, 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("", 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(`<<<

text

`, 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 +} diff --git a/shortcuts/doc/internal/docxparse/profile.go b/shortcuts/doc/internal/docxparse/profile.go new file mode 100644 index 0000000000..878ec5e147 --- /dev/null +++ b/shortcuts/doc/internal/docxparse/profile.go @@ -0,0 +1,328 @@ +// 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 checks XML syntax, then builds its structure and visible-text profile. +// XML business semantics are deliberately left to the document writer and +// service. +func Parse(source string, format Format) (ParseResult, error) { + if format != FormatXML { + return ParseResult{}, newParseError("unsupported input format %q", format) + } + nodes, err := parseXML(source) + if err != nil { + return ParseResult{}, err + } + if err := validateNestingDepth(nodes); err != nil { + return ParseResult{}, err + } + return ParseResult{ + Format: format, + XML: source, + Profile: buildProfile(nodes), + }, nil +} + +// ParseCompatibleXML builds a profile after deterministic recovery of common +// malformed XML emitted while authoring a draft. +func ParseCompatibleXML(source string) (Profile, error) { + if err := validateSource(source); err != nil { + return Profile{}, err + } + trimmed := strings.TrimSpace(strings.TrimPrefix(source, "\uFEFF")) + if !strings.HasPrefix(trimmed, "<") { + return Profile{}, newParseError("XML input must begin with '<'") + } + nodes, err := parseXMLCompatible(source) + if err != nil { + return Profile{}, err + } + return buildProfile(nodes), nil +} + +func validateNestingDepth(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]}) + } + 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 { + depth-- + continue + } + if depth >= MaxNestingDepth { + return newParseError("document nesting exceeds limit %d at <%s>", MaxNestingDepth, 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 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), kind: segmentMarker}) + 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 + } + before := out.Len() + for _, child := range current.children { + walk(child) + } + if current != node && out.Len() == before { + if display := firstNonEmpty(current.attrs["text"], current.attrs["name"], current.attrs["title"], current.attrs["alt"]); display != "" { + out.WriteString(display) + } + } + } + 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 "" +} diff --git a/shortcuts/doc/internal/docxparse/wordcount.go b/shortcuts/doc/internal/docxparse/wordcount.go new file mode 100644 index 0000000000..5494f3537e --- /dev/null +++ b/shortcuts/doc/internal/docxparse/wordcount.go @@ -0,0 +1,352 @@ +// 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 c.lexeme == lexemeNone && isASCIIAlphaNumericByte(value[offset]) { + 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 isASCIIAlphaNumericByte(ch byte) bool { + return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' +} + +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.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 == '-') + 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 +} diff --git a/shortcuts/doc/internal/docxparse/xml.go b/shortcuts/doc/internal/docxparse/xml.go new file mode 100644 index 0000000000..4adb7d33b5 --- /dev/null +++ b/shortcuts/doc/internal/docxparse/xml.go @@ -0,0 +1,797 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package docxparse + +import ( + "html" + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +const ( + MaxInputBytes = 20_000_000 + MaxNestingDepth = 1024 +) + +func validateSource(source string) error { + if len(source) > MaxInputBytes { + return newParseError("input is too large (%d bytes, limit %d)", len(source), MaxInputBytes) + } + if !utf8.ValidString(source) { + return newParseError("input must be valid UTF-8") + } + for offset, r := range source { + if !isXML10Character(r) { + return newParseError("input contains an XML 1.0 forbidden character U+%04X at byte %d", r, offset) + } + } + if containsForbiddenXMLDeclaration(source) { + return newParseError("XML input must not contain DOCTYPE or ENTITY declarations") + } + return nil +} + +func isXML10Character(r rune) bool { + return r == '\t' || r == '\n' || r == '\r' || + r >= 0x20 && r <= 0xD7FF || + r >= 0xE000 && r <= 0xFFFD || + r >= 0x10000 && r <= 0x10FFFF +} + +func containsForbiddenXMLDeclaration(source string) bool { + for offset := 0; offset < len(source); { + relative := strings.IndexByte(source[offset:], '<') + if relative < 0 { + return false + } + start := offset + relative + switch { + case strings.HasPrefix(source[start:], ""); end >= 0 { + offset = start + 4 + end + len("-->") + continue + } + return false + case strings.HasPrefix(source[start:], ""); end >= 0 { + offset = start + len("") + continue + } + return false + case strings.HasPrefix(source[start:], ""); end >= 0 { + offset = start + 2 + end + len("?>") + continue + } + return false + } + + position := start + 1 + if position < len(source) && source[position] == '!' { + position++ + for position < len(source) && isXMLSpace(source[position]) { + position++ + } + for _, declaration := range []string{"DOCTYPE", "ENTITY"} { + end := position + len(declaration) + if end <= len(source) && strings.EqualFold(source[position:end], declaration) && + (end == len(source) || !isTagNamePart(source[end])) { + return true + } + } + } + offset = start + 1 + } + return false +} + +func parseXML(source string) ([]*Node, error) { + return parseXMLWithCompatibility(source, false) +} + +func parseXMLCompatible(source string) ([]*Node, error) { + return parseXMLWithCompatibility(source, true) +} + +func parseXMLWithCompatibility(source string, compatible bool) ([]*Node, error) { + if err := validateSource(source); err != nil { + return nil, err + } + if compatible { + source = normalizeCompatibleXMLInput(source) + } + source = strings.TrimPrefix(source, "\uFEFF") + + root := newElement("__fragment__", nil) + stack := []*Node{root} +parseLoop: + for i := 0; i < len(source); { + lt := strings.IndexByte(source[i:], '<') + if lt < 0 { + if !compatible { + if err := validateXMLText(source[i:], i); err != nil { + return nil, err + } + } + appendText(stack[len(stack)-1], source[i:]) + break + } + lt += i + if !compatible { + 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: + if !compatible { + return nil, newParseError("invalid XML token at byte %d", lt) + } + if strings.HasPrefix(source[lt:], ""); closeAt >= 0 { + i = lt + 4 + closeAt + len("-->") + continue + } + if nextRelative := strings.IndexByte(source[lt+4:], '<'); nextRelative >= 0 { + i = lt + 4 + nextRelative + continue + } + break parseLoop + } + if token.name == "" { + next := compatibleTokenBoundary(source, lt, end) + if closingTag, ok := scanCompatibleIncompleteClosingTag(source[lt:next]); ok { + stack = closeCompatibleStack(stack, closingTag) + i = next + continue + } + if openingTag, ok := scanCompatibleIncompleteOpeningTag(source[lt:next]); ok { + token = openingTag + end = next + break + } + if end > lt+1 { + appendTextValue(stack[len(stack)-1], source[lt:end]) + i = end + } else { + appendTextValue(stack[len(stack)-1], "<") + i = lt + 1 + } + continue + } + case tokenIncomplete: + if !compatible { + return nil, newParseError("unterminated XML tag at byte %d", lt) + } + switch { + case strings.HasPrefix(source[lt:], "= 0 { + i = lt + prefixLength + nextRelative + continue + } + break parseLoop + } + next := compatibleTokenBoundary(source, lt, end) + if closingTag, ok := scanCompatibleIncompleteClosingTag(source[lt:next]); ok { + stack = closeCompatibleStack(stack, closingTag) + i = next + continue + } + if openingTag, ok := scanCompatibleIncompleteOpeningTag(source[lt:next]); ok { + token = openingTag + end = next + break + } + appendTextValue(stack[len(stack)-1], source[lt:next]) + i = next + continue + } + + tag := token.name + if compatible && !isKnownTag(tag) { + i = end + continue + } + if token.spacingNormalized && !compatible { + return nil, newParseError("invalid whitespace in XML tag <%s> at byte %d", token.name, lt) + } + if compatible && stack[len(stack)-1].tag == "whiteboard" && + !(token.closing && tag == "whiteboard") && + !(!token.closing && tag == "br") { + i = end + continue + } + + if token.closing { + if isVoidTag(tag) { + if compatible { + i = end + continue + } + return nil, newParseError("void tag <%s/> must not have a closing tag", tag) + } + if len(stack) == 1 { + if compatible { + i = end + continue + } + return nil, newParseError("unexpected closing tag at byte %d", tag, lt) + } + open := stack[len(stack)-1].tag + if open != tag { + if compatible { + stack = closeCompatibleStack(stack, tag) + i = end + continue + } + return nil, newParseError("mismatched closing tag at byte %d; expected ", tag, lt, open) + } + stack = stack[:len(stack)-1] + i = end + continue + } + + if compatible && len(stack) > 1 && shouldAutoClose(stack[len(stack)-1].tag, tag) { + for len(stack) > 1 && shouldAutoClose(stack[len(stack)-1].tag, tag) { + stack = stack[:len(stack)-1] + } + } + node := newElement(tag, token.attrs) + stack[len(stack)-1].addChild(node) + if !token.selfClosing && !isVoidTag(tag) { + if len(stack) > MaxNestingDepth { + return nil, newParseError("XML nesting exceeds limit %d at byte %d", MaxNestingDepth, lt) + } + stack = append(stack, node) + } + i = end + } + + if len(stack) > 1 && !compatible { + return nil, newParseError("missing closing tag 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 +} + +func compatibleTokenBoundary(source string, start, scannedEnd int) int { + if nextRelative := strings.IndexByte(source[start+1:], '<'); nextRelative >= 0 { + return start + 1 + nextRelative + } + if scannedEnd > start { + return scannedEnd + } + return len(source) +} + +func scanCompatibleIncompleteClosingTag(fragment string) (string, bool) { + position := 0 + if position >= len(fragment) || fragment[position] != '<' { + return "", false + } + position++ + for position < len(fragment) && isXMLSpace(fragment[position]) { + position++ + } + if position >= len(fragment) || fragment[position] != '/' { + return "", false + } + position++ + for position < len(fragment) && isXMLSpace(fragment[position]) { + position++ + } + if position >= len(fragment) || !isTagNameStart(fragment[position]) { + return "", false + } + nameStart := position + position++ + for position < len(fragment) && isTagNamePart(fragment[position]) { + position++ + } + if strings.TrimSpace(fragment[position:]) != "" { + return "", false + } + tag := fragment[nameStart:position] + if !isKnownTag(tag) || isVoidTag(tag) { + return "", false + } + return tag, true +} + +func scanCompatibleIncompleteOpeningTag(fragment string) (xmlToken, bool) { + fragment = strings.TrimRightFunc(fragment, unicode.IsSpace) + if fragment == "" || strings.HasSuffix(fragment, ">") { + return xmlToken{}, false + } + synthetic := fragment + ">" + token, end, state := scanXMLToken(synthetic, 0) + if end != len(synthetic) || token.name == "" || token.closing || + state != tokenOK && state != tokenInvalid { + return xmlToken{}, false + } + return token, true +} + +func closeCompatibleStack(stack []*Node, tag string) []*Node { + for i := len(stack) - 1; i > 0; i-- { + if stack[i].tag == tag { + return stack[:i] + } + } + return stack +} + +// normalizeParsedLineBreaks removes formatting newlines from ordinary XML, +// while source-bearing code/whiteboard blocks keep semantic +// line breaks as explicit
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", " ", "\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:], ""); closeAt >= 0 { + contentEnd := contentStart + closeAt + return xmlToken{text: source[contentStart:contentEnd]}, contentEnd + len("]]>"), tokenCDATA + } + return xmlToken{}, len(source), tokenIncomplete + } + if strings.HasPrefix(source[start:], ""); 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:], ""); 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 token, end, tokenInvalid + } + return token, end, tokenOK + } + if strings.HasSuffix(remainder, "/") { + token.selfClosing = true + if len(remainder) != len(rawRemainder) { + return token, end, tokenInvalid + } + remainder = strings.TrimRightFunc(strings.TrimSuffix(remainder, "/"), unicode.IsSpace) + } + trimmedAttrs := strings.TrimLeftFunc(remainder, unicode.IsSpace) + if trimmedAttrs != "" && !isAttributeNameStart(trimmedAttrs[0]) { + token.attrs = parseAttributes(remainder) + return token, end, tokenInvalid + } + var ok bool + token.attrs, ok = parseStrictAttributes(remainder) + if !ok { + token.attrs = parseAttributes(remainder) + return token, 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 compatibility +// recovery of malformed authoring output. +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 := 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 newParseError("invalid ]]> sequence in XML text at byte %d", absoluteOffset+offset) + } + if offset := invalidXMLEntityAt(value); offset >= 0 { + return newParseError("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 +} diff --git a/shortcuts/doc/internal/docxparse/xml_compat.go b/shortcuts/doc/internal/docxparse/xml_compat.go new file mode 100644 index 0000000000..349f4ee8af --- /dev/null +++ b/shortcuts/doc/internal/docxparse/xml_compat.go @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package docxparse + +import ( + "regexp" + "strings" +) + +var ( + compatibleBlockIDSelfClosing = regexp.MustCompile(`^`) + compatibleBlockIDWithClosing = regexp.MustCompile(`^\s*`) + compatibleBlockIDOpen = regexp.MustCompile(`^`) +) + +// normalizeCompatibleXMLInput applies deterministic legacy-shape rewrites +// before the tolerant parser builds its in-memory tree. The repaired XML is +// intentionally not exposed or written back by docs +script parse. +func normalizeCompatibleXMLInput(source string) string { + var out strings.Builder + out.Grow(len(source)) + for offset := 0; offset < len(source); { + relative := strings.IndexByte(source[offset:], '<') + if relative < 0 { + out.WriteString(source[offset:]) + break + } + start := offset + relative + out.WriteString(source[offset:start]) + + _, end, state := scanXMLToken(source, start) + if state == tokenComment || state == tokenCDATA || state == tokenProcessingInstruction { + out.WriteString(source[start:end]) + offset = end + continue + } + + if replacement, consumed, ok := rewriteCompatibleBlockID(source[start:]); ok { + out.WriteString(replacement) + offset = start + consumed + continue + } + if end > start+1 && (state == tokenOK || state == tokenInvalid) { + out.WriteString(source[start:end]) + offset = end + continue + } + + out.WriteByte(source[start]) + offset = start + 1 + } + return out.String() +} + +func rewriteCompatibleBlockID(source string) (string, int, bool) { + for _, expression := range []*regexp.Regexp{ + compatibleBlockIDSelfClosing, + compatibleBlockIDWithClosing, + compatibleBlockIDOpen, + } { + match := expression.FindStringSubmatchIndex(source) + if len(match) < 4 || match[0] != 0 { + continue + } + return `` + source[match[2]:match[3]] + ``, match[1], true + } + return "", 0, false +} diff --git a/shortcuts/doc/local_doc_resources.go b/shortcuts/doc/local_doc_resources.go new file mode 100644 index 0000000000..874b1a95dd --- /dev/null +++ b/shortcuts/doc/local_doc_resources.go @@ -0,0 +1,2841 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "image" + "io" + "math" + "math/big" + "mime" + "net/http" + "net/url" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + localDocResourceBindBatchSize = 20 + localDocResourceCleanupBatchSize = 200 + localDocResourceBindAttempts = 3 + localDocResourceUploadAttempts = 3 + remoteDocImageDownloadAttempts = 3 + remoteDocImageUploadConcurrency = 10 + localDocResourceUploadInterval = 220 * time.Millisecond + localDocResourceBindInterval = 350 * time.Millisecond + localDocResourceVerifyInterval = 220 * time.Millisecond + localDocResourceUploadConflictCode = 1061045 + localDocResourceUploadRateLimitCode = 99991400 + localDocImageMaxDisplayWidthPx = 1020 + localDocImageScalePrecision = 1000000 + remoteDocImageMaxBytes = int64(20 * 1024 * 1024) +) + +var waitLocalDocResourceRequest = func(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func localDocResourceRetryDelay(base time.Duration, attempt int) time.Duration { + delay := base * time.Duration(1<= fenceLen && markdownFenceCloses(line, char, run) { + fenceChar, fenceLen = 0, 0 + } + continue + } + if indentedCode { + flush() + out.WriteString(line) + continue + } + segment.WriteString(line) + } + flush() + if rewriteErr != nil { + return "", nil, rewriteErr + } + result := out.String() + for _, inert := range inertSpans { + result = strings.ReplaceAll(result, inert.Protected, inert.Original) + } + return result, resources, nil +} + +type protectedLocalDocResourceSpan struct { + Protected string + Original string +} + +func protectMarkdownLocalDocResourceMarkup(content string) (string, []protectedLocalDocResourceSpan) { + var out strings.Builder + spans := make([]protectedLocalDocResourceSpan, 0) + nextToken := 0 + for i := 0; i < len(content); { + end := -1 + switch { + case strings.HasPrefix(content[i:], ""); offset >= 0 { + end = i + 4 + offset + 3 + } else { + end = len(content) + } + case strings.HasPrefix(content[i:], ""); offset >= 0 { + end = i + 9 + offset + 3 + } else { + end = len(content) + } + default: + if rawEnd, ok := findMarkdownRawHTMLInertEnd(content, i); ok { + end = rawEnd + } + } + if end < 0 { + out.WriteByte(content[i]) + i++ + continue + } + + original := content[i:end] + protected := "" + for { + protected = fmt.Sprintf("\ue000lark_cli_inert_%d\ue001", nextToken) + nextToken++ + if !strings.Contains(content, protected) { + break + } + } + protected += strings.Repeat("\n", strings.Count(original, "\n")) + out.WriteString(protected) + spans = append(spans, protectedLocalDocResourceSpan{Protected: protected, Original: original}) + i = end + } + return out.String(), spans +} + +func rewriteLocalDocResourceSegment(runtime *common.RuntimeContext, segment string, markdownMode bool, localRefs map[string]struct{}, resources *[]localDocResource) (string, error) { + var out strings.Builder + for i := 0; i < len(segment); { + if markdownMode { + if end, ok := findMarkdownRawHTMLInertEnd(segment, i); ok { + out.WriteString(segment[i:end]) + i = end + continue + } + } + if strings.HasPrefix(segment[i:], "") + if end < 0 { + out.WriteString(segment[i:]) + break + } + end += i + 7 + out.WriteString(segment[i:end]) + i = end + continue + } + if strings.HasPrefix(segment[i:], "") + if end < 0 { + out.WriteString(segment[i:]) + break + } + end += i + 12 + out.WriteString(segment[i:end]) + i = end + continue + } + if markdownMode && segment[i] == '`' { + run := countByteRun(segment, i, '`') + end := findMatchingBacktickRun(segment, i+run, run) + if end < 0 { + out.WriteString(segment[i : i+run]) + i += run + continue + } + out.WriteString(segment[i:end]) + i = end + continue + } + + if segment[i] == '<' && !(markdownMode && isEscapedMarkdownByte(segment, i)) { + name := localResourceTagNameAt(segment, i) + if name != "" { + end := findXMLStartTagEnd(segment, i) + if end < 0 { + return "", common.ValidationErrorf("invalid <%s> local resource tag", name).WithParam(name) + } + raw := segment[i:end] + rewritten, resource, changed, err := rewriteRawLocalResourceTag(runtime, raw, name, len(*resources)+1) + if err != nil { + return "", err + } + out.WriteString(rewritten) + if changed { + *resources = append(*resources, resource) + } + i = end + continue + } + } + + if segment[i] == '!' && i+1 < len(segment) && segment[i+1] == '[' && !isEscapedMarkdownByte(segment, i) { + image, ok := parseMarkdownImageAt(segment, i) + if ok { + if image.ReferenceLabel != "" { + if _, local := localRefs[normalizeMarkdownReferenceLabel(image.ReferenceLabel)]; local { + return "", common.ValidationErrorf("local Markdown reference-style images are not supported; use ![alt](@relative/path) instead").WithParam("content") + } + out.WriteString(segment[i:image.End]) + i = image.End + continue + } + if strings.HasPrefix(image.Destination, "@") { + resource, err := newLocalDocResource(runtime, localDocResourceImage, image.Destination, len(*resources)+1) + if err != nil { + return "", err + } + out.WriteString(`") + *resources = append(*resources, resource) + i = image.End + continue + } + out.WriteString(segment[i:image.End]) + i = image.End + continue + } + } + + out.WriteByte(segment[i]) + i++ + } + return out.String(), nil +} + +func rewriteRawLocalResourceTag(runtime *common.RuntimeContext, raw, name string, occurrence int) (string, localDocResource, bool, error) { + tag, err := parseLocalDocResourceTag(raw, name) + if err != nil { + return "", localDocResource{}, false, common.ValidationErrorf("invalid <%s> local resource tag: %v", name, err).WithParam(name).WithCause(err) + } + pathValue, hasPath := tag.attr("path") + if !hasPath { + if name != "img" { + return raw, localDocResource{}, false, nil + } + href, hasHref := tag.attr("href") + if !hasHref { + return raw, localDocResource{}, false, nil + } + conflicts := []string{"src", "token", "img_key", "img-key", "url"} + for _, conflict := range conflicts { + if tag.hasAttr(conflict) { + return "", localDocResource{}, false, common.ValidationErrorf(" href cannot be combined with %s", conflict).WithParam("img") + } + } + resource, err := newRemoteDocImageResource(href, occurrence) + if err != nil { + return "", localDocResource{}, false, err + } + tag.renameAttr("href", "path") + tag.setAttr("path", resource.Marker) + if _, hasCaption := tag.attr("caption"); !hasCaption { + tag.renameAttr("alt", "caption") + } + resource.RequestedImagePresentation = captureLocalDocImagePresentation(tag) + resource.captureImagePresentation(tag) + for _, name := range []string{"width", "height", "align", "scale"} { + tag.deleteAttr(name) + } + return tag.render(), resource, true, nil + } + + conflicts := []string{"src", "href", "token", "img_key", "img-key", "url"} + for _, conflict := range conflicts { + if tag.hasAttr(conflict) { + return "", localDocResource{}, false, common.ValidationErrorf("<%s> local path cannot be combined with %s", name, conflict).WithParam(name) + } + } + + kind := localDocResourceImage + if name == "source" { + kind = localDocResourceFile + } + resource, err := newLocalDocResource(runtime, kind, pathValue, occurrence) + if err != nil { + return "", localDocResource{}, false, err + } + tag.setAttr("path", resource.Marker) + if kind == localDocResourceImage { + if _, hasCaption := tag.attr("caption"); !hasCaption { + tag.renameAttr("alt", "caption") + } + if err := normalizeLocalDocImagePresentation(runtime, &tag, &resource); err != nil { + return "", localDocResource{}, false, localResourceValidationErrorWithCause(kind, occurrence, "file is not a supported BMP, GIF, JPEG, PNG, TIFF, or WebP image", err) + } + resource.captureImagePresentation(tag) + } else if rawName, hasName := tag.attr("name"); hasName { + fileName := strings.TrimSpace(rawName) + if fileName == "" || fileName == "." || fileName == ".." || strings.ContainsAny(fileName, `/\\`) { + return "", localDocResource{}, false, common.ValidationErrorf(" name must be a non-empty file name without path separators").WithParam("source") + } + resource.FileName = fileName + tag.setAttr("name", fileName) + } + return tag.render(), resource, true, nil +} + +func newRemoteDocImageResource(rawURL string, occurrence int) (localDocResource, error) { + rawURL = strings.TrimSpace(rawURL) + u, err := url.Parse(rawURL) + if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || strings.TrimSpace(u.Hostname()) == "" || u.User != nil { + return localDocResource{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "remote image #%d href must be an absolute HTTP(S) URL without userinfo", + occurrence, + ).WithParam("href") + } + marker, err := newLocalDocResourceMarker(localDocResourceImage) + if err != nil { + return localDocResource{}, errs.NewInternalError(errs.SubtypeUnknown, "failed to generate remote image marker").WithCause(err) + } + return localDocResource{ + Occurrence: occurrence, + Kind: localDocResourceImage, + Marker: marker, + RemoteURL: u.String(), + }, nil +} + +type remoteDocImageDownload struct { + Content []byte + FileName string + Width int + Height int +} + +var ( + downloadRemoteDocImage = downloadRemoteDocImageContent + doRemoteDocImageRequest = func(client remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) { + return client.Do(req) + } +) + +type remoteDocImageHTTPDoer interface { + Do(*http.Request) (*http.Response, error) +} + +func downloadRemoteDocImageContent(runtime *common.RuntimeContext, rawURL string, occurrence int) (remoteDocImageDownload, error) { + if err := validateRemoteDocImageSource(runtime.Ctx(), rawURL, occurrence); err != nil { + return remoteDocImageDownload{}, err + } + baseClient, err := runtime.Factory.ExternalHTTPClient() + if err != nil { + return remoteDocImageDownload{}, errs.NewInternalError(errs.SubtypeSDKError, "initialize remote image HTTP client: %v", err).WithCause(err) + } + client := validate.NewDownloadHTTPClient(baseClient, validate.DownloadHTTPClientOptions{AllowHTTP: true, MaxRedirects: 5}) + req, err := http.NewRequestWithContext(runtime.Ctx(), http.MethodGet, rawURL, nil) //nolint:forbidigo // guarded download of user-provided image URL + if err != nil { + return remoteDocImageDownload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid remote image #%d href: %v", occurrence, err).WithParam("href").WithCause(err) + } + resp, err := doRemoteDocImageRequest(client, req) + if err != nil { + return remoteDocImageDownload{}, remoteDocImageNetworkError(err, occurrence, "request failed") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + subtype := errs.SubtypeNetworkTransport + retryable := false + switch { + case resp.StatusCode == http.StatusTooManyRequests: + subtype = errs.SubtypeRateLimit + retryable = true + case resp.StatusCode >= 500: + subtype = errs.SubtypeNetworkServer + retryable = true + } + httpErr := errs.NewNetworkError(subtype, "download remote image #%d failed: HTTP %d", occurrence, resp.StatusCode).WithCode(resp.StatusCode) + if retryable { + httpErr.WithRetryable() + } + return remoteDocImageDownload{}, httpErr + } + mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return remoteDocImageDownload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d response has an invalid Content-Type", occurrence).WithParam("href").WithCause(err) + } + ext, ok := docCoverAllowedContentTypes[strings.ToLower(mediaType)] + if !ok { + return remoteDocImageDownload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d response Content-Type %q is not supported", occurrence, mediaType).WithParam("href") + } + if resp.ContentLength > remoteDocImageMaxBytes { + return remoteDocImageDownload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d exceeds 20MiB limit", occurrence).WithParam("href") + } + + body := &remoteDocImageResponseReader{reader: resp.Body} + content, err := io.ReadAll(io.LimitReader(body, remoteDocImageMaxBytes+1)) + if err != nil { + if body.err != nil { + return remoteDocImageDownload{}, remoteDocImageNetworkError(body.err, occurrence, "response body failed") + } + if contextErr := runtime.Ctx().Err(); contextErr != nil { + return remoteDocImageDownload{}, remoteDocImageNetworkError(contextErr, occurrence, "was interrupted") + } + return remoteDocImageDownload{}, remoteDocImageNetworkError(err, occurrence, "response body failed") + } + if len(content) == 0 { + return remoteDocImageDownload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d response is empty", occurrence).WithParam("href") + } + if int64(len(content)) > remoteDocImageMaxBytes { + return remoteDocImageDownload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d exceeds 20MiB limit", occurrence).WithParam("href") + } + config, detectedFormat, err := image.DecodeConfig(bytes.NewReader(content)) + if err != nil { + return remoteDocImageDownload{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "remote image #%d response body is not a valid %s image", + occurrence, + mediaType, + ).WithParam("href").WithCause(err) + } + expectedFormat := strings.TrimPrefix(strings.ToLower(mediaType), "image/") + if expectedFormat == "jpeg" && detectedFormat == "jpg" { + detectedFormat = "jpeg" + } + if detectedFormat != expectedFormat { + return remoteDocImageDownload{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "remote image #%d response declared %s but contains %s image data", + occurrence, + mediaType, + detectedFormat, + ).WithParam("href") + } + return remoteDocImageDownload{ + Content: content, + FileName: "image" + ext, + Width: config.Width, + Height: config.Height, + }, nil +} + +type remoteDocImageResponseReader struct { + reader io.Reader + err error +} + +func (r *remoteDocImageResponseReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + if err != nil && !errors.Is(err, io.EOF) { + r.err = err + } + return n, err +} + +func remoteDocImageNetworkError(err error, occurrence int, action string) error { + if _, ok := errs.ProblemOf(err); ok { + return err + } + subtype := errs.SubtypeNetworkTransport + if errors.Is(err, context.DeadlineExceeded) { + subtype = errs.SubtypeNetworkTimeout + } + networkErr := errs.NewNetworkError(subtype, "download remote image #%d %s", occurrence, action).WithCause(err) + if !errors.Is(err, context.Canceled) { + networkErr.WithRetryable() + } + return networkErr +} + +func validateRemoteDocImageSource(ctx context.Context, rawURL string, occurrence int) error { + if err := validate.ValidateDownloadSourceURL(ctx, rawURL); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "remote image #%d href is not allowed: %v", occurrence, err). + WithParam("href"). + WithHint("use a public HTTP(S) image URL, or save the image in the draft workspace and reference it with "). + WithCause(err) + } + return nil +} + +func validateRemoteDocImageSources(ctx context.Context, resources []localDocResource) error { + for _, resource := range resources { + if resource.RemoteURL == "" { + continue + } + if err := validateRemoteDocImageSource(ctx, resource.RemoteURL, resource.Occurrence); err != nil { + return err + } + } + return nil +} + +func probeRemoteDocImageDownload(runtime *common.RuntimeContext, rawURL string, occurrence int) error { + if err := validateRemoteDocImageSource(runtime.Ctx(), rawURL, occurrence); err != nil { + return err + } + baseClient, err := runtime.Factory.ExternalHTTPClient() + if err != nil { + return errs.NewInternalError(errs.SubtypeSDKError, "initialize remote image HTTP client: %v", err).WithCause(err) + } + client := validate.NewDownloadHTTPClient(baseClient, validate.DownloadHTTPClientOptions{AllowHTTP: true, MaxRedirects: 5}) + return probeRemoteDocImageRequest(runtime, client, rawURL, occurrence) +} + +func probeRemoteDocImageRequest(runtime *common.RuntimeContext, client remoteDocImageHTTPDoer, rawURL string, occurrence int) error { + req, err := http.NewRequestWithContext(runtime.Ctx(), http.MethodGet, rawURL, nil) //nolint:forbidigo // guarded probe of user-provided image URL + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid remote image #%d href: %v", occurrence, err).WithParam("href").WithCause(err) + } + req.Header.Set("Range", "bytes=0-0") + resp, err := doRemoteDocImageRequest(client, req) + if err != nil { + return remoteDocImageNetworkError(err, occurrence, "availability probe failed") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return remoteDocImageHTTPStatusError(occurrence, resp.StatusCode, "probe") + } + if resp.ContentLength > remoteDocImageMaxBytes { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d exceeds 20MiB limit", occurrence).WithParam("href") + } + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if contentType == "" { + return nil + } + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d response has an invalid Content-Type", occurrence).WithParam("href").WithCause(err) + } + if _, ok := docCoverAllowedContentTypes[strings.ToLower(mediaType)]; !ok { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d response Content-Type %q is not supported", occurrence, mediaType).WithParam("href") + } + return nil +} + +func remoteDocImageHTTPStatusError(occurrence, statusCode int, action string) error { + subtype := errs.SubtypeNetworkTransport + retryable := false + switch { + case statusCode == http.StatusTooManyRequests: + subtype = errs.SubtypeRateLimit + retryable = true + case statusCode >= 500: + subtype = errs.SubtypeNetworkServer + retryable = true + } + httpErr := errs.NewNetworkError(subtype, "%s remote image #%d failed: HTTP %d", action, occurrence, statusCode).WithCode(statusCode) + if retryable { + httpErr.WithRetryable() + } + return httpErr +} + +func applyRemoteDocImageDownload(resource *localDocResource, download remoteDocImageDownload) error { + if len(download.Content) == 0 || download.Width <= 0 || download.Height <= 0 { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "remote image download is missing content or dimensions") + } + resource.FileName = download.FileName + resource.Size = int64(len(download.Content)) + tag := resource.RequestedImagePresentation.tag() + normalizeDocImagePresentation(&tag, resource, download.Width, download.Height) + resource.ImageWidth = 0 + resource.ImageHeight = 0 + resource.ImageAlign = "" + resource.ImageScale = 0 + resource.HasScale = false + resource.captureImagePresentation(tag) + return nil +} + +func captureLocalDocImagePresentation(tag localDocResourceTag) localDocImagePresentation { + presentation := localDocImagePresentation{} + presentation.Width, _ = tag.attr("width") + presentation.Height, _ = tag.attr("height") + presentation.Align, _ = tag.attr("align") + presentation.Scale, _ = tag.attr("scale") + return presentation +} + +func (p localDocImagePresentation) tag() localDocResourceTag { + tag := localDocResourceTag{Name: "img", SelfClosing: true} + for _, attr := range []struct { + name string + value string + }{ + {name: "width", value: p.Width}, + {name: "height", value: p.Height}, + {name: "align", value: p.Align}, + {name: "scale", value: p.Scale}, + } { + if attr.value != "" { + tag.setAttr(attr.name, attr.value) + } + } + return tag +} + +func (r *localDocResource) captureImagePresentation(tag localDocResourceTag) { + if raw, ok := tag.attr("width"); ok { + if value, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil && value > 0 { + r.ImageWidth = value + } + } + if raw, ok := tag.attr("height"); ok { + if value, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil && value > 0 { + r.ImageHeight = value + } + } + if raw, ok := tag.attr("align"); ok { + align := strings.ToLower(strings.TrimSpace(raw)) + if _, supported := alignMap[align]; supported { + r.ImageAlign = align + } + } + if raw, ok := tag.attr("scale"); ok { + if value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64); err == nil && value > 0 { + r.ImageScale = value + r.HasScale = true + } + } +} + +// normalizeLocalDocImagePresentation mirrors the SDK's network-image +// normalization before the local path is replaced by an opaque marker. The +// stored width/height are always the source image's intrinsic pixel +// dimensions; model-provided display dimensions are converted to scale. +func normalizeLocalDocImagePresentation(runtime *common.RuntimeContext, tag *localDocResourceTag, resource *localDocResource) error { + nativeWidth, nativeHeight, err := detectImageDimensionsFromPath(runtime.FileIO(), resource.Path) + if err != nil || nativeWidth <= 0 || nativeHeight <= 0 { + if err != nil { + return err + } + return invalidLocalDocImageDimensionsError() + } + normalizeDocImagePresentation(tag, resource, nativeWidth, nativeHeight) + return nil +} + +func normalizeDocImagePresentation(tag *localDocResourceTag, resource *localDocResource, nativeWidth, nativeHeight int) { + modelScale, hasModelScale := positiveLocalDocImageFloatAttr(*tag, "scale") + modelWidth, hasModelWidth := positiveLocalDocImageDisplaySizeAttr(*tag, "width", nativeWidth) + modelHeight, hasModelHeight := positiveLocalDocImageDisplaySizeAttr(*tag, "height", nativeHeight) + + tag.setAttr("width", strconv.Itoa(nativeWidth)) + tag.setAttr("height", strconv.Itoa(nativeHeight)) + + scale, hasScale := resolveLocalDocImageScale( + nativeWidth, + nativeHeight, + modelScale, + hasModelScale, + modelWidth, + hasModelWidth, + modelHeight, + hasModelHeight, + ) + if !hasScale { + tag.deleteAttr("scale") + return + } + tag.setAttr("scale", strconv.FormatFloat(scale, 'f', 6, 64)) +} + +func resolveLocalDocImageScale(nativeWidth, nativeHeight int, modelScale float64, hasModelScale bool, modelWidth float64, hasModelWidth bool, modelHeight float64, hasModelHeight bool) (float64, bool) { + var scale float64 + switch { + case hasModelScale: + scale = modelScale + case hasModelWidth: + scale = modelWidth / float64(nativeWidth) + case hasModelHeight: + scale = modelHeight / float64(nativeHeight) + case nativeWidth >= localDocImageMaxDisplayWidthPx: + scale = 1 + default: + return 0, false + } + scale = floorLocalDocImageScalePrecision(scale) + return capLocalDocImageScaleBelowPageWidth(nativeWidth, scale), true +} + +func positiveLocalDocImageFloatAttr(tag localDocResourceTag, name string) (float64, bool) { + value, ok := tag.attr(name) + if !ok { + return 0, false + } + return positiveLocalDocImageFloat(strings.TrimSpace(value)) +} + +func positiveLocalDocImageDisplaySizeAttr(tag localDocResourceTag, name string, nativeSize int) (float64, bool) { + value, ok := tag.attr(name) + if !ok { + return 0, false + } + value = strings.TrimSpace(value) + if strings.HasSuffix(value, "%") { + percentage, valid := positiveLocalDocImageFloat(strings.TrimSpace(strings.TrimSuffix(value, "%"))) + if !valid { + return 0, false + } + return float64(nativeSize) * percentage / 100, true + } + return positiveLocalDocImageFloat(value) +} + +func positiveLocalDocImageFloat(value string) (float64, bool) { + if value == "" { + return 0, false + } + parsed, err := strconv.ParseFloat(value, 64) + if err != nil || parsed <= 0 || math.IsNaN(parsed) || math.IsInf(parsed, 0) { + return 0, false + } + return parsed, true +} + +func floorLocalDocImageScalePrecision(scale float64) float64 { + floored := math.Floor(scale*localDocImageScalePrecision) / localDocImageScalePrecision + if floored <= 0 { + return scale + } + return floored +} + +func capLocalDocImageScaleBelowPageWidth(nativeWidth int, scale float64) float64 { + maxScale := float64(localDocImageMaxDisplayWidthPx) / float64(nativeWidth) + if scale < maxScale { + return scale + } + capped := math.Floor(maxScale*localDocImageScalePrecision) / localDocImageScalePrecision + if capped >= maxScale { + capped -= 1.0 / localDocImageScalePrecision + } + if capped <= 0 { + return math.Nextafter(maxScale, 0) + } + return capped +} + +func newLocalDocResource(runtime *common.RuntimeContext, kind localDocResourceKind, pathValue string, occurrence int) (localDocResource, error) { + pathValue = strings.TrimSpace(pathValue) + if !strings.HasPrefix(pathValue, "@") { + return localDocResource{}, localResourceValidationError(kind, occurrence, "path must start with @") + } + if isReservedLocalDocResourceMarker(pathValue) { + return localDocResource{}, localResourceValidationError(kind, occurrence, "path uses a reserved lark-cli marker") + } + relPath := strings.TrimSpace(strings.TrimPrefix(pathValue, "@")) + clean := filepath.Clean(relPath) + if relPath == "" || filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return localDocResource{}, localResourceValidationError(kind, occurrence, "path must be a relative file inside the current working directory") + } + + info, err := runtime.FileIO().Stat(clean) + if err != nil { + return localDocResource{}, localResourceValidationErrorWithCause(kind, occurrence, "file does not exist or its path is unsafe", err) + } + if !info.Mode().IsRegular() { + return localDocResource{}, localResourceValidationError(kind, occurrence, "path must point to a regular file") + } + if info.Size() <= 0 { + return localDocResource{}, localResourceValidationError(kind, occurrence, "file must not be empty") + } + file, err := runtime.FileIO().Open(clean) + if err != nil { + return localDocResource{}, localResourceValidationErrorWithCause(kind, occurrence, "file is not readable", err) + } + if err := file.Close(); err != nil { + return localDocResource{}, localResourceValidationErrorWithCause(kind, occurrence, "file could not be closed after validation", err) + } + var imageWidth, imageHeight int + if kind == localDocResourceImage { + imageWidth, imageHeight, _, err = detectImageConfigFromPath(runtime.FileIO(), clean) + if err != nil || imageWidth <= 0 || imageHeight <= 0 { + if err == nil { + err = invalidLocalDocImageDimensionsError() + } + return localDocResource{}, localResourceValidationErrorWithCause(kind, occurrence, "file is not a supported BMP, GIF, JPEG, PNG, TIFF, or WebP image", err) + } + } + + marker, err := newLocalDocResourceMarker(kind) + if err != nil { + return localDocResource{}, errs.NewInternalError(errs.SubtypeUnknown, "failed to generate local resource marker").WithCause(err) + } + return localDocResource{ + Occurrence: occurrence, + Kind: kind, + Marker: marker, + Path: clean, + FileName: filepath.Base(clean), + Size: info.Size(), + ImageWidth: imageWidth, + ImageHeight: imageHeight, + }, nil +} + +func invalidLocalDocImageDimensionsError() error { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "decoded image has invalid dimensions") +} + +func newLocalDocResourceMarker(kind localDocResourceKind) (string, error) { + raw := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, raw); err != nil { + return "", err + } + prefix := "@lcli_img_" + if kind == localDocResourceFile { + prefix = "@lcli_file_" + } + return prefix + hex.EncodeToString(raw), nil +} + +func localResourceValidationError(kind localDocResourceKind, occurrence int, reason string) error { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "local %s #%d: %s", kind, occurrence, reason).WithParam("path") +} + +func localResourceValidationErrorWithCause(kind localDocResourceKind, occurrence int, reason string, cause error) error { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "local %s #%d: %s", kind, occurrence, reason).WithParam("path").WithCause(cause) +} + +func validateLocalDocResourceUpdateCommand(command string, resources []localDocResource) error { + if len(resources) == 0 || command == "append" || command == "block_insert_after" || command == "block_replace" || command == "overwrite" { + return nil + } + return errs.NewValidationError( + errs.SubtypeInvalidArgument, + "local images and files are only supported with --command append, block_insert_after, block_replace, or overwrite", + ).WithParams( + errs.InvalidParam{Name: "--command", Reason: "use append, block_insert_after, block_replace, or overwrite for local resources"}, + errs.InvalidParam{Name: "--content", Reason: "contains local image or file input"}, + ) +} + +func finalizeLocalDocResources(runtime *common.RuntimeContext, documentKey string, data map[string]interface{}, resources []localDocResource) error { + if len(resources) == 0 { + return nil + } + if data == nil { + data = map[string]interface{}{} + } + outcomes := correlateLocalDocResources(data, resources) + if strings.TrimSpace(documentKey) == "" { + for _, outcome := range outcomes { + outcome.Status = "correlation_failed" + outcome.CleanupStatus = "skipped" + outcome.Err = errs.NewInternalError(errs.SubtypeInvalidResponse, "document response is missing document_id") + } + appendLocalDocResourceFailures(data, outcomes) + scrubLocalDocResourceResponseMarkers(data) + return runtime.OutPartialFailure(data, nil) + } + + uploadLocalDocResources(runtime, documentKey, outcomes) + lastRevision := localDocResourceRevisionFromDocsAI(data) + revisionKnown := lastRevision != nil + bindRevision, bindRevisionKnown := bindLocalDocResources(runtime, documentKey, outcomes) + if bindRevision != nil { + lastRevision = bindRevision + revisionKnown = true + } else if !bindRevisionKnown { + lastRevision = nil + revisionKnown = false + } + cleanupRevision, cleanupRevisionKnown := cleanupLocalDocResourcePlaceholders(runtime, documentKey, outcomes, lastRevision) + if cleanupRevision != nil { + lastRevision = cleanupRevision + revisionKnown = true + } else if !cleanupRevisionKnown { + lastRevision = nil + revisionKnown = false + } + if revisionKnown && lastRevision != nil { + setLocalDocResourceRevision(data, lastRevision) + } else if !revisionKnown { + clearLocalDocResourceRevision(data) + } + + failed := false + for _, outcome := range outcomes { + if outcome.Status == "bound" { + if outcome.Block != nil { + outcome.Block["block_token"] = outcome.FileToken + } + continue + } + failed = true + if outcome.Block != nil { + delete(outcome.Block, "block_token") + } + } + scrubLocalDocResourceResponseMarkers(data) + if !failed { + return nil + } + appendLocalDocResourceFailures(data, outcomes) + return runtime.OutPartialFailure(data, nil) +} + +func appendLocalDocResourceFailures(data map[string]interface{}, outcomes []*localDocResourceOutcome) { + failures := make([]interface{}, 0) + for _, outcome := range outcomes { + if outcome == nil || outcome.Status == "bound" { + continue + } + failure := map[string]interface{}{ + "occurrence": outcome.Resource.Occurrence, + "kind": outcome.Resource.Kind, + "status": outcome.Status, + "cleanup_status": outcome.CleanupStatus, + } + if problem, ok := errs.ProblemOf(outcome.Err); ok { + detail := map[string]interface{}{ + "type": problem.Category, + "subtype": problem.Subtype, + } + if problem.Code != 0 { + detail["code"] = problem.Code + } + if problem.Retryable { + detail["retryable"] = true + } + failure["error"] = detail + } + if len(outcome.ServerWarnings) > 0 { + failure["server_warnings"] = outcome.ServerWarnings + } + failures = append(failures, failure) + } + if len(failures) > 0 { + data["local_resource_failures"] = failures + } +} + +func scrubLocalDocResourceResponseMarkers(data map[string]interface{}) { + doc, _ := data["document"].(map[string]interface{}) + for _, raw := range common.GetSlice(doc, "new_blocks") { + block, _ := raw.(map[string]interface{}) + if block == nil { + continue + } + marker := strings.TrimSpace(common.GetString(block, "block_token")) + if isReservedLocalDocResourceMarker(marker) { + delete(block, "block_token") + } + } +} + +func correlateLocalDocResources(data map[string]interface{}, resources []localDocResource) []*localDocResourceOutcome { + outcomes := make([]*localDocResourceOutcome, len(resources)) + byMarker := make(map[string][]map[string]interface{}, len(resources)) + expectedMarkers := make(map[string]struct{}, len(resources)) + for _, resource := range resources { + expectedMarkers[resource.Marker] = struct{}{} + } + unknownBlocks := make([]map[string]interface{}, 0) + doc, _ := data["document"].(map[string]interface{}) + for _, raw := range common.GetSlice(doc, "new_blocks") { + block, _ := raw.(map[string]interface{}) + if block == nil { + continue + } + marker := strings.TrimSpace(common.GetString(block, "block_token")) + if marker == "" { + continue + } + if _, expected := expectedMarkers[marker]; expected { + byMarker[marker] = append(byMarker[marker], block) + } else if isReservedLocalDocResourceMarker(marker) { + unknownBlocks = append(unknownBlocks, block) + } + } + + for i, resource := range resources { + outcome := &localDocResourceOutcome{ + Resource: resource, + Status: "pending", + CleanupStatus: "not_needed", + } + matches := byMarker[resource.Marker] + if len(matches) != 1 { + outcome.Status = "correlation_failed" + outcome.Err = errs.NewInternalError(errs.SubtypeInvalidResponse, "SDK returned %d blocks for local %s #%d; expected exactly one", len(matches), resource.Kind, resource.Occurrence) + allBlocksMatchKind := true + for _, block := range matches { + if !localDocResourceBlockMatchesKind(block, resource.Kind) { + allBlocksMatchKind = false + } + if id := strings.TrimSpace(common.GetString(block, "block_id")); id != "" { + outcome.CleanupBlockIDs = append(outcome.CleanupBlockIDs, id) + } + } + outcome.SafeToCleanup = len(outcome.CleanupBlockIDs) > 0 && allBlocksMatchKind + if len(matches) == 0 || !allBlocksMatchKind { + outcome.CleanupStatus = "skipped_ambiguous" + } + outcomes[i] = outcome + continue + } + + block := matches[0] + blockID := strings.TrimSpace(common.GetString(block, "block_id")) + blockType := strings.TrimSpace(common.GetString(block, "block_type")) + outcome.Block = block + outcome.BlockID = blockID + blockMatchesKind := localDocResourceBlockMatchesKind(block, resource.Kind) + if blockID != "" && blockMatchesKind { + outcome.CleanupBlockIDs = []string{blockID} + outcome.SafeToCleanup = true + } + if blockID == "" || !blockMatchesKind { + outcome.Status = "correlation_failed" + outcome.Err = errs.NewInternalError(errs.SubtypeInvalidResponse, "SDK returned an invalid block for local %s #%d", resource.Kind, resource.Occurrence) + if blockID != "" && blockType != string(resource.Kind) { + outcome.CleanupStatus = "skipped_ambiguous" + } + } + outcomes[i] = outcome + } + if len(unknownBlocks) > 0 { + for _, outcome := range outcomes { + outcome.Status = "correlation_failed" + outcome.CleanupStatus = "skipped_ambiguous" + outcome.SafeToCleanup = false + outcome.Err = errs.NewInternalError(errs.SubtypeInvalidResponse, "SDK returned unrecognized local resource markers; no local uploads were started") + } + return outcomes + } + byBlockID := make(map[string][]*localDocResourceOutcome, len(outcomes)) + for _, outcome := range outcomes { + if outcome.BlockID != "" { + byBlockID[outcome.BlockID] = append(byBlockID[outcome.BlockID], outcome) + } + } + for blockID, conflicts := range byBlockID { + if len(conflicts) < 2 { + continue + } + blockIDSafeToCleanup := true + for _, outcome := range conflicts { + if !outcome.SafeToCleanup { + blockIDSafeToCleanup = false + break + } + } + for i, outcome := range conflicts { + outcome.Status = "correlation_failed" + outcome.Err = errs.NewInternalError(errs.SubtypeInvalidResponse, "SDK correlated %d local resources to the same block_id", len(conflicts)) + if i == 0 && blockIDSafeToCleanup { + found := false + for _, cleanupBlockID := range outcome.CleanupBlockIDs { + if cleanupBlockID == blockID { + found = true + break + } + } + if !found { + outcome.CleanupBlockIDs = append(outcome.CleanupBlockIDs, blockID) + } + continue + } + outcome.CleanupBlockIDs = nil + outcome.SafeToCleanup = false + if blockIDSafeToCleanup { + outcome.CleanupStatus = "skipped_duplicate" + } else { + outcome.CleanupStatus = "skipped_ambiguous" + } + } + } + return outcomes +} + +func uploadLocalDocResources(runtime *common.RuntimeContext, documentKey string, outcomes []*localDocResourceOutcome) { + remoteOutcomes := make([]*localDocResourceOutcome, 0) + localOutcomes := make([]*localDocResourceOutcome, 0, len(outcomes)) + for _, outcome := range outcomes { + if outcome.Status != "pending" { + continue + } + if outcome.Resource.RemoteURL != "" { + remoteOutcomes = append(remoteOutcomes, outcome) + continue + } + localOutcomes = append(localOutcomes, outcome) + } + uploadRemoteDocImages(runtime, documentKey, remoteOutcomes) + uploadLocalDocResourcesSerially(runtime, documentKey, localOutcomes) +} + +func uploadRemoteDocImages(runtime *common.RuntimeContext, documentKey string, outcomes []*localDocResourceOutcome) { + if len(outcomes) == 0 { + return + } + // Resolve credentials before fan-out so lazy credential-source selection + // completes on the caller goroutine. Upload requests still resolve the + // current token through the standard API path. + if _, err := runtime.AccessToken(); err != nil { + for _, outcome := range outcomes { + markLocalDocResourceUploadFailed(outcome, err) + } + return + } + workerCount := min(remoteDocImageUploadConcurrency, len(outcomes)) + jobs := make(chan *localDocResourceOutcome) + done := make(chan struct{}, workerCount) + for range workerCount { + go func() { + defer func() { done <- struct{}{} }() + for outcome := range jobs { + uploadLocalDocResource(runtime, documentKey, outcome) + } + }() + } + for _, outcome := range outcomes { + jobs <- outcome + } + close(jobs) + for range workerCount { + <-done + } +} + +func uploadLocalDocResourcesSerially(runtime *common.RuntimeContext, documentKey string, outcomes []*localDocResourceOutcome) { + started := false + for _, outcome := range outcomes { + if started { + if err := waitLocalDocResourceRequest(runtime.Ctx(), localDocResourceUploadInterval); err != nil { + markLocalDocResourceUploadFailed(outcome, err) + continue + } + } + started = true + uploadLocalDocResource(runtime, documentKey, outcome) + } +} + +func uploadLocalDocResource(runtime *common.RuntimeContext, documentKey string, outcome *localDocResourceOutcome) { + var content []byte + if outcome.Resource.RemoteURL != "" { + outcome.Resource.Content = nil + download, err := downloadRemoteDocImageWithRetry(runtime, outcome.Resource.RemoteURL, outcome.Resource.Occurrence) + if err != nil { + markLocalDocResourceUploadFailed(outcome, err) + return + } + if err := applyRemoteDocImageDownload(&outcome.Resource, download); err != nil { + markLocalDocResourceUploadFailed(outcome, err) + return + } + content = download.Content + outcome.Resource.Content = nil + defer func() { + content = nil + outcome.Resource.Content = nil + }() + } else if len(outcome.Resource.Content) > 0 { + content = outcome.Resource.Content + } + + var uploadErr error + for attempt := 0; attempt < localDocResourceUploadAttempts; attempt++ { + upload := UploadDocMediaFileConfig{ + FilePath: outcome.Resource.Path, + FileName: outcome.Resource.FileName, + FileSize: outcome.Resource.Size, + ParentType: parentTypeForMediaType(string(outcome.Resource.Kind)), + ParentNode: outcome.BlockID, + DocID: documentKey, + } + if len(content) > 0 { + upload.Reader = bytes.NewReader(content) + } + token, err := uploadDocMediaFile(runtime, upload) + if err == nil { + outcome.FileToken = token + outcome.Status = "uploaded" + return + } + uploadErr = err + if !isRetryableLocalDocResourceUpload(err) || attempt+1 >= localDocResourceUploadAttempts { + break + } + delay := localDocResourceRetryDelay(localDocResourceUploadInterval, attempt) + if waitErr := waitLocalDocResourceRequest(runtime.Ctx(), delay); waitErr != nil { + uploadErr = errors.Join(uploadErr, waitErr) + break + } + } + markLocalDocResourceUploadFailed(outcome, uploadErr) +} + +func downloadRemoteDocImageWithRetry(runtime *common.RuntimeContext, rawURL string, occurrence int) (remoteDocImageDownload, error) { + var downloadErr error + for attempt := 0; attempt < remoteDocImageDownloadAttempts; attempt++ { + download, err := downloadRemoteDocImage(runtime, rawURL, occurrence) + if err == nil { + return download, nil + } + downloadErr = err + if !isRetryableLocalDocResourceNetwork(err) || attempt+1 >= remoteDocImageDownloadAttempts { + break + } + delay := localDocResourceRetryDelay(localDocResourceUploadInterval, attempt) + if waitErr := waitLocalDocResourceRequest(runtime.Ctx(), delay); waitErr != nil { + downloadErr = errors.Join(downloadErr, waitErr) + break + } + } + return remoteDocImageDownload{}, downloadErr +} + +func markLocalDocResourceUploadFailed(outcome *localDocResourceOutcome, err error) { + outcome.Status = "upload_failed" + outcome.CleanupStatus = "pending" + outcome.Err = err +} + +func isRetryableLocalDocResourceUpload(err error) bool { + if isRetryableLocalDocResourceNetwork(err) { + return true + } + problem, ok := errs.ProblemOf(err) + return ok && (problem.Code == localDocResourceUploadConflictCode || problem.Code == localDocResourceUploadRateLimitCode) +} + +func isRetryableLocalDocResourceNetwork(err error) bool { + if errors.Is(err, context.Canceled) { + return false + } + if errs.IsRetryable(err) { + return true + } + problem, ok := errs.ProblemOf(err) + if !ok { + return false + } + if problem.Category != errs.CategoryNetwork { + return false + } + return problem.Subtype == errs.SubtypeNetworkTransport || + problem.Subtype == errs.SubtypeNetworkTimeout || + problem.Subtype == errs.SubtypeNetworkServer || + problem.Subtype == errs.SubtypeRateLimit || + problem.Code == http.StatusTooManyRequests || + problem.Code >= http.StatusInternalServerError +} + +func bindLocalDocResources(runtime *common.RuntimeContext, documentKey string, outcomes []*localDocResourceOutcome) (interface{}, bool) { + ready := make([]*localDocResourceOutcome, 0, len(outcomes)) + for _, outcome := range outcomes { + if outcome.Status == "uploaded" { + ready = append(ready, outcome) + } + } + var lastRevision interface{} + revisionKnown := true + for start := 0; start < len(ready); start += localDocResourceBindBatchSize { + end := min(start+localDocResourceBindBatchSize, len(ready)) + chunk := ready[start:end] + if start > 0 { + if err := waitLocalDocResourceRequest(runtime.Ctx(), localDocResourceBindInterval); err != nil { + markLocalDocResourceBindFailed(ready[start:], err) + break + } + } + revision, chunkRevisionKnown := bindLocalDocResourceChunk(runtime, documentKey, chunk) + if revision != nil { + lastRevision = revision + revisionKnown = true + } else if !chunkRevisionKnown { + lastRevision = nil + revisionKnown = false + } + } + return lastRevision, revisionKnown +} + +func bindLocalDocResourceChunk(runtime *common.RuntimeContext, documentKey string, chunk []*localDocResourceOutcome) (interface{}, bool) { + clientToken := uuid.NewString() + body := buildLocalDocResourceBatchUpdate(chunk) + url := fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/batch_update", validate.EncodePathSegment(documentKey)) + var lastErr error + for attempt := 0; attempt < localDocResourceBindAttempts; attempt++ { + data, err := runtime.CallAPITyped("PATCH", url, map[string]interface{}{"client_token": clientToken}, body) + if err == nil { + for _, outcome := range chunk { + outcome.Status = "bound" + outcome.CleanupStatus = "not_needed" + outcome.SafeToCleanup = false + } + revision := localDocResourceRevisionFromBatch(data) + return revision, revision != nil + } + lastErr = err + allBound, hasConflict, hasUnknown := verifyLocalDocResourceChunk(runtime, documentKey, chunk, err) + if allBound { + return nil, false + } + if hasConflict || hasUnknown || !errs.IsRetryable(err) { + break + } + if attempt+1 < localDocResourceBindAttempts { + delay := localDocResourceBindInterval * time.Duration(1< 0 { + if err := waitLocalDocResourceRequest(runtime.Ctx(), localDocResourceVerifyInterval); err != nil { + outcome.Status = "bind_ambiguous" + outcome.CleanupStatus = "skipped_ambiguous" + outcome.SafeToCleanup = false + outcome.Err = errors.Join(bindErr, err) + allBound = false + hasUnknown = true + continue + } + } + data, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", validate.EncodePathSegment(documentKey), validate.EncodePathSegment(outcome.BlockID)), nil, nil) + if err != nil { + outcome.Status = "bind_ambiguous" + outcome.CleanupStatus = "skipped_ambiguous" + outcome.SafeToCleanup = false + outcome.Err = errors.Join(bindErr, err) + allBound = false + hasUnknown = true + continue + } + actualToken := localDocResourceBlockToken(common.GetMap(data, "block"), outcome.Resource.Kind) + switch actualToken { + case outcome.FileToken: + outcome.Status = "bound" + outcome.CleanupStatus = "not_needed" + outcome.SafeToCleanup = false + case "": + outcome.Status = "uploaded" + outcome.SafeToCleanup = true + allBound = false + default: + outcome.Status = "bind_conflict" + outcome.CleanupStatus = "skipped_conflict" + outcome.SafeToCleanup = false + outcome.Err = errs.NewInternalError(errs.SubtypeInvalidResponse, "local %s #%d block token changed unexpectedly; placeholder was preserved", outcome.Resource.Kind, outcome.Resource.Occurrence) + allBound = false + hasConflict = true + } + } + return allBound, hasConflict, hasUnknown +} + +func buildLocalDocResourceBatchUpdate(chunk []*localDocResourceOutcome) map[string]interface{} { + requests := make([]interface{}, 0, len(chunk)) + for _, outcome := range chunk { + request := map[string]interface{}{"block_id": outcome.BlockID} + if outcome.Resource.Kind == localDocResourceFile { + request["replace_file"] = map[string]interface{}{"token": outcome.FileToken} + } else { + replaceImage := map[string]interface{}{"token": outcome.FileToken} + if outcome.Resource.ImageWidth > 0 { + replaceImage["width"] = outcome.Resource.ImageWidth + } + if outcome.Resource.ImageHeight > 0 { + replaceImage["height"] = outcome.Resource.ImageHeight + } + if align, ok := alignMap[outcome.Resource.ImageAlign]; ok { + replaceImage["align"] = align + } + if outcome.Resource.HasScale { + replaceImage["scale"] = outcome.Resource.ImageScale + } + request["replace_image"] = replaceImage + } + requests = append(requests, request) + } + return map[string]interface{}{"requests": requests} +} + +func cleanupLocalDocResourcePlaceholders(runtime *common.RuntimeContext, documentKey string, outcomes []*localDocResourceOutcome, baseRevision interface{}) (interface{}, bool) { + type cleanupTarget struct { + BlockID string + Owner *localDocResourceOutcome + } + targets := make([]cleanupTarget, 0) + seenBlockIDs := make(map[string]struct{}) + for _, outcome := range outcomes { + if outcome.Status == "bound" || !outcome.SafeToCleanup { + continue + } + for _, blockID := range outcome.CleanupBlockIDs { + blockID = strings.TrimSpace(blockID) + if blockID == "" { + continue + } + if _, duplicate := seenBlockIDs[blockID]; duplicate { + continue + } + seenBlockIDs[blockID] = struct{}{} + targets = append(targets, cleanupTarget{BlockID: blockID, Owner: outcome}) + } + if len(outcome.CleanupBlockIDs) == 0 && outcome.CleanupStatus == "not_needed" { + outcome.CleanupStatus = "skipped" + } + } + if len(targets) == 0 { + return nil, true + } + baseRevision = normalizeLocalDocResourceRevision(baseRevision) + if baseRevision == nil { + err := errs.NewInternalError(errs.SubtypeInvalidResponse, "document response is missing a revision; local resource placeholders were preserved") + for _, target := range targets { + target.Owner.CleanupStatus = "skipped_ambiguous" + target.Owner.SafeToCleanup = false + appendLocalDocResourceOutcomeError(target.Owner, err) + } + return nil, false + } + + verifiedTargets := make([]cleanupTarget, 0, len(targets)) + blockedOwners := make(map[*localDocResourceOutcome]struct{}) + for i, target := range targets { + if i > 0 { + if err := waitLocalDocResourceRequest(runtime.Ctx(), localDocResourceVerifyInterval); err != nil { + target.Owner.CleanupStatus = "skipped_ambiguous" + target.Owner.SafeToCleanup = false + blockedOwners[target.Owner] = struct{}{} + appendLocalDocResourceOutcomeError(target.Owner, err) + continue + } + } + data, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", validate.EncodePathSegment(documentKey), validate.EncodePathSegment(target.BlockID)), nil, nil) + if err != nil { + target.Owner.Status = "bind_ambiguous" + target.Owner.CleanupStatus = "skipped_ambiguous" + target.Owner.SafeToCleanup = false + blockedOwners[target.Owner] = struct{}{} + appendLocalDocResourceOutcomeError(target.Owner, err) + continue + } + block := common.GetMap(data, "block") + if !localDocResourceBlockMatchesKind(block, target.Owner.Resource.Kind) { + blockedOwners[target.Owner] = struct{}{} + target.Owner.Status = "bind_ambiguous" + target.Owner.CleanupStatus = "skipped_ambiguous" + target.Owner.SafeToCleanup = false + appendLocalDocResourceOutcomeError(target.Owner, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "local %s #%d block type could not be verified before cleanup; placeholder was preserved", + target.Owner.Resource.Kind, + target.Owner.Resource.Occurrence, + )) + continue + } + + actualTokens := localDocResourceBlockTokens(block) + if len(actualTokens) == 0 { + if target.Owner.Resource.Kind == localDocResourceFile { + // DocX represents a file as a source child inside a figure + // block. Deleting the source ID is accepted by docs_ai but + // leaves an empty
behind, so remove + // the owning figure after the tokenless child is verified. + parentID := strings.TrimSpace(common.GetString(block, "parent_id")) + if parentID == "" || parentID == target.BlockID || parentID == documentKey { + blockedOwners[target.Owner] = struct{}{} + target.Owner.Status = "bind_ambiguous" + target.Owner.CleanupStatus = "skipped_ambiguous" + target.Owner.SafeToCleanup = false + appendLocalDocResourceOutcomeError(target.Owner, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "local file #%d figure parent could not be verified before cleanup; placeholder was preserved", + target.Owner.Resource.Occurrence, + )) + continue + } + parentData, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", validate.EncodePathSegment(documentKey), validate.EncodePathSegment(parentID)), nil, nil) + if err != nil { + blockedOwners[target.Owner] = struct{}{} + target.Owner.Status = "bind_ambiguous" + target.Owner.CleanupStatus = "skipped_ambiguous" + target.Owner.SafeToCleanup = false + appendLocalDocResourceOutcomeError(target.Owner, err) + continue + } + parentBlock := common.GetMap(parentData, "block") + if !localDocResourceIsSoleFileFigure(parentBlock, parentID, target.BlockID) { + blockedOwners[target.Owner] = struct{}{} + target.Owner.Status = "bind_ambiguous" + target.Owner.CleanupStatus = "skipped_ambiguous" + target.Owner.SafeToCleanup = false + appendLocalDocResourceOutcomeError(target.Owner, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "local file #%d parent is not a sole-source figure; placeholder was preserved", + target.Owner.Resource.Occurrence, + )) + continue + } + target.BlockID = parentID + } + verifiedTargets = append(verifiedTargets, target) + continue + } + + blockedOwners[target.Owner] = struct{}{} + target.Owner.SafeToCleanup = false + if target.Owner.FileToken != "" && len(actualTokens) == 1 && actualTokens[0] == target.Owner.FileToken { + target.Owner.Status = "bound" + target.Owner.CleanupStatus = "not_needed" + if target.Owner.Block != nil { + target.Owner.Block["block_token"] = target.Owner.FileToken + } + continue + } + target.Owner.Status = "bind_conflict" + target.Owner.CleanupStatus = "skipped_conflict" + appendLocalDocResourceOutcomeError(target.Owner, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "local %s #%d block token changed before cleanup; placeholder was preserved", + target.Owner.Resource.Kind, + target.Owner.Resource.Occurrence, + )) + } + verifiedByBlockID := make(map[string]cleanupTarget, len(verifiedTargets)) + for _, target := range verifiedTargets { + if previous, duplicate := verifiedByBlockID[target.BlockID]; duplicate { + blockedOwners[previous.Owner] = struct{}{} + blockedOwners[target.Owner] = struct{}{} + for _, owner := range []*localDocResourceOutcome{previous.Owner, target.Owner} { + owner.Status = "bind_ambiguous" + owner.CleanupStatus = "skipped_ambiguous" + owner.SafeToCleanup = false + appendLocalDocResourceOutcomeError(owner, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "multiple local resources resolved to cleanup block %s; placeholders were preserved", + target.BlockID, + )) + } + continue + } + verifiedByBlockID[target.BlockID] = target + } + deleteTargets := verifiedTargets[:0] + for _, target := range verifiedTargets { + if _, blocked := blockedOwners[target.Owner]; !blocked { + deleteTargets = append(deleteTargets, target) + } + } + if len(deleteTargets) == 0 { + return nil, false + } + + var lastRevision interface{} + currentRevision := baseRevision + for start := 0; start < len(deleteTargets); start += localDocResourceCleanupBatchSize { + end := min(start+localDocResourceCleanupBatchSize, len(deleteTargets)) + chunk := deleteTargets[start:end] + ids := make([]string, 0, len(chunk)) + for _, target := range chunk { + ids = append(ids, target.BlockID) + } + body := map[string]interface{}{ + "format": "xml", + "command": "block_delete", + "block_id": strings.Join(ids, ","), + "revision_id": currentRevision, + } + injectDocsScene(runtime, body) + data, err := doDocAPI(runtime, "PUT", fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", validate.EncodePathSegment(documentKey)), body) + if err != nil { + for _, target := range chunk { + target.Owner.CleanupStatus = "failed" + target.Owner.SafeToCleanup = false + appendLocalDocResourceOutcomeError(target.Owner, err) + } + for _, target := range deleteTargets[end:] { + target.Owner.CleanupStatus = "skipped_ambiguous" + target.Owner.SafeToCleanup = false + appendLocalDocResourceOutcomeError(target.Owner, errs.NewInternalError(errs.SubtypeInvalidResponse, "cleanup revision could not be confirmed; placeholder was preserved")) + } + return nil, false + } + if docsAPIOperationFailed(data) { + serviceErr := errs.NewAPIError(errs.SubtypeUnknown, "local resource placeholder cleanup returned result=failed") + warnings := common.GetSlice(data, "warnings") + for _, target := range chunk { + target.Owner.CleanupStatus = "failed" + target.Owner.SafeToCleanup = false + target.Owner.ServerWarnings = append(target.Owner.ServerWarnings, warnings...) + appendLocalDocResourceOutcomeError(target.Owner, serviceErr) + } + for _, target := range deleteTargets[end:] { + target.Owner.CleanupStatus = "skipped_ambiguous" + target.Owner.SafeToCleanup = false + appendLocalDocResourceOutcomeError(target.Owner, errs.NewInternalError(errs.SubtypeInvalidResponse, "cleanup revision could not be confirmed; placeholder was preserved")) + } + return nil, false + } + for _, target := range chunk { + target.Owner.CleanupStatus = "succeeded" + target.Owner.SafeToCleanup = false + } + revision := localDocResourceRevisionFromDocsAI(data) + if revision == nil { + for _, target := range deleteTargets[end:] { + target.Owner.CleanupStatus = "skipped_ambiguous" + target.Owner.SafeToCleanup = false + appendLocalDocResourceOutcomeError(target.Owner, errs.NewInternalError(errs.SubtypeInvalidResponse, "cleanup response is missing a revision; placeholder was preserved")) + } + return nil, false + } + lastRevision = revision + currentRevision = revision + } + return lastRevision, true +} + +func appendLocalDocResourceOutcomeError(outcome *localDocResourceOutcome, err error) { + if err == nil { + return + } + if outcome.Err == nil { + outcome.Err = err + return + } + outcome.Err = errors.Join(outcome.Err, err) +} + +func localDocResourceBlockToken(block map[string]interface{}, kind localDocResourceKind) string { + if block == nil { + return "" + } + if token := strings.TrimSpace(common.GetString(block, "token")); token != "" { + return token + } + return strings.TrimSpace(common.GetString(common.GetMap(block, string(kind)), "token")) +} + +func localDocResourceBlockMatchesKind(block map[string]interface{}, kind localDocResourceKind) bool { + if block == nil { + return false + } + rawType, ok := block["block_type"] + if !ok { + return false + } + if blockType, ok := rawType.(string); ok { + return strings.TrimSpace(blockType) == string(kind) + } + blockType, ok := normalizeLocalDocResourceRevision(rawType).(int64) + return ok && blockType == int64(blockTypeForMediaType(string(kind))) +} + +func localDocResourceIsSoleFileFigure(block map[string]interface{}, expectedBlockID, childBlockID string) bool { + if block == nil || strings.TrimSpace(common.GetString(block, "block_id")) != expectedBlockID { + return false + } + rawType, ok := block["block_type"] + if !ok { + return false + } + figureType := false + if blockType, ok := rawType.(string); ok { + switch strings.ToLower(strings.TrimSpace(blockType)) { + case "figure", "view": + figureType = true + } + } else if blockType, ok := normalizeLocalDocResourceRevision(rawType).(int64); ok { + figureType = blockType == 33 + } + if !figureType { + return false + } + children := common.GetSlice(block, "children") + if len(children) != 1 { + return false + } + child, ok := children[0].(string) + return ok && strings.TrimSpace(child) == childBlockID +} + +func localDocResourceBlockTokens(block map[string]interface{}) []string { + if block == nil { + return nil + } + tokens := make([]string, 0, 3) + seen := make(map[string]struct{}, 3) + for _, token := range []string{ + common.GetString(block, "token"), + common.GetString(common.GetMap(block, string(localDocResourceImage)), "token"), + common.GetString(common.GetMap(block, string(localDocResourceFile)), "token"), + } { + token = strings.TrimSpace(token) + if token == "" { + continue + } + if _, duplicate := seen[token]; duplicate { + continue + } + seen[token] = struct{}{} + tokens = append(tokens, token) + } + return tokens +} + +func localDocResourceRevisionFromBatch(data map[string]interface{}) interface{} { + if data == nil { + return nil + } + return normalizeLocalDocResourceRevision(data["document_revision_id"]) +} + +func localDocResourceRevisionFromDocsAI(data map[string]interface{}) interface{} { + doc, _ := data["document"].(map[string]interface{}) + if doc == nil { + return nil + } + return normalizeLocalDocResourceRevision(doc["revision_id"]) +} + +func normalizeLocalDocResourceRevision(value interface{}) interface{} { + var revision int64 + switch number := value.(type) { + case int: + revision = int64(number) + case int8: + revision = int64(number) + case int16: + revision = int64(number) + case int32: + revision = int64(number) + case int64: + revision = number + case uint: + if uint64(number) > math.MaxInt64 { + return nil + } + revision = int64(number) + case uint8: + revision = int64(number) + case uint16: + revision = int64(number) + case uint32: + revision = int64(number) + case uint64: + if number > math.MaxInt64 { + return nil + } + revision = int64(number) + case float32: + value := float64(number) + if value > math.MaxInt64 || value < 0 || math.Trunc(value) != value { + return nil + } + revision = int64(value) + case float64: + if number > math.MaxInt64 || number < 0 || math.Trunc(number) != number { + return nil + } + revision = int64(number) + case json.Number: + parsed, err := strconv.ParseInt(number.String(), 10, 64) + if err != nil { + return nil + } + revision = parsed + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(number), 10, 64) + if err != nil { + return nil + } + revision = parsed + default: + return nil + } + if revision < 0 { + return nil + } + return revision +} + +func setLocalDocResourceRevision(data map[string]interface{}, revision interface{}) { + doc, _ := data["document"].(map[string]interface{}) + if doc != nil { + if revision = normalizeLocalDocResourceRevision(revision); revision != nil { + doc["revision_id"] = revision + } + } +} + +func clearLocalDocResourceRevision(data map[string]interface{}) { + doc, _ := data["document"].(map[string]interface{}) + if doc != nil { + delete(doc, "revision_id") + } +} + +func appendLocalDocResourcesDryRun(dry *common.DryRunAPI, documentKey string, resources []localDocResource) *common.DryRunAPI { + if len(resources) == 0 { + return dry + } + encodedDocumentKey := validate.EncodePathSegment(documentKey) + routeExtra, _ := buildDriveRouteExtra(documentKey) + for _, resource := range resources { + parentType := parentTypeForMediaType(string(resource.Kind)) + body := map[string]interface{}{ + "file_name": fmt.Sprintf("", resource.Kind, resource.Occurrence), + "parent_type": parentType, + "parent_node": fmt.Sprintf("", resource.Kind, resource.Occurrence), + "size": resource.Size, + "extra": routeExtra, + } + if resource.Size > common.MaxDriveMediaUploadSinglePartSize { + dry.POST("/open-apis/drive/v1/medias/upload_prepare"). + Desc(fmt.Sprintf("Upload local %s #%d: initialize multipart upload", resource.Kind, resource.Occurrence)). + Body(body). + POST("/open-apis/drive/v1/medias/upload_part"). + Desc(fmt.Sprintf("Upload local %s #%d: upload parts (repeated)", resource.Kind, resource.Occurrence)). + Body(map[string]interface{}{"upload_id": "", "seq": "", "size": "", "file": ""}). + POST("/open-apis/drive/v1/medias/upload_finish"). + Desc(fmt.Sprintf("Upload local %s #%d: finish multipart upload", resource.Kind, resource.Occurrence)). + Body(map[string]interface{}{"upload_id": "", "block_num": ""}) + } else { + body["file"] = "" + dry.POST("/open-apis/drive/v1/medias/upload_all"). + Desc(fmt.Sprintf("Upload local %s #%d", resource.Kind, resource.Occurrence)). + Body(body) + } + } + for start := 0; start < len(resources); start += localDocResourceBindBatchSize { + end := min(start+localDocResourceBindBatchSize, len(resources)) + requests := make([]interface{}, 0, end-start) + for _, resource := range resources[start:end] { + request := map[string]interface{}{"block_id": fmt.Sprintf("", resource.Kind, resource.Occurrence)} + if resource.Kind == localDocResourceFile { + request["replace_file"] = map[string]interface{}{"token": fmt.Sprintf("", resource.Occurrence)} + } else { + replaceImage := map[string]interface{}{"token": fmt.Sprintf("", resource.Occurrence)} + if resource.ImageWidth > 0 { + replaceImage["width"] = resource.ImageWidth + } + if resource.ImageHeight > 0 { + replaceImage["height"] = resource.ImageHeight + } + if align, ok := alignMap[resource.ImageAlign]; ok { + replaceImage["align"] = align + } + if resource.HasScale { + replaceImage["scale"] = resource.ImageScale + } + request["replace_image"] = replaceImage + } + requests = append(requests, request) + } + dry.PATCH(fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/batch_update", encodedDocumentKey)). + Desc(fmt.Sprintf("Bind uploaded local resources (batch %d, max %d requests)", start/localDocResourceBindBatchSize+1, localDocResourceBindBatchSize)). + Params(map[string]interface{}{"client_token": fmt.Sprintf("", start/localDocResourceBindBatchSize+1)}). + Body(map[string]interface{}{"requests": requests}) + } + dry.GET(fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", encodedDocumentKey, validate.EncodePathSegment(""))). + Desc("Conditional: verify block token after an ambiguous bind response or immediately before cleanup") + dry.PUT(fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", encodedDocumentKey)). + Desc("Conditional: delete placeholders whose upload or bind failed; successful resources are preserved"). + Body(map[string]interface{}{"format": "xml", "command": "block_delete", "block_id": "", "revision_id": ""}) + return dry +} + +func appendRemoteDocImageDownloadsDryRun(dry *common.DryRunAPI, resources []localDocResource) *common.DryRunAPI { + for _, resource := range resources { + if resource.RemoteURL == "" { + continue + } + dry.GET(redactRemoteDocImageURL(resource.RemoteURL)). + Desc(fmt.Sprintf("Download remote image #%d in a bounded concurrent upload worker after the document write succeeds (userinfo, query, and fragment are redacted)", resource.Occurrence)) + } + return dry +} + +func redactRemoteDocImageURL(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil || parsed == nil { + return "" + } + parsed.User = nil + parsed.RawQuery = "" + parsed.ForceQuery = false + parsed.Fragment = "" + return parsed.String() +} + +func (t localDocResourceTag) attr(name string) (string, bool) { + for _, attr := range t.Attrs { + if attr.Name == name { + return attr.Value, true + } + } + return "", false +} + +func (t localDocResourceTag) hasAttr(name string) bool { + _, ok := t.attr(name) + return ok +} + +func (t *localDocResourceTag) setAttr(name, value string) { + for i := range t.Attrs { + if t.Attrs[i].Name == name { + t.Attrs[i].Value = value + return + } + } + t.Attrs = append(t.Attrs, html5BlockAttr{Name: name, Value: value}) +} + +func (t *localDocResourceTag) deleteAttr(name string) { + attrs := t.Attrs[:0] + for _, attr := range t.Attrs { + if attr.Name != name { + attrs = append(attrs, attr) + } + } + t.Attrs = attrs +} + +func (t *localDocResourceTag) renameAttr(oldName, newName string) bool { + if t.hasAttr(newName) { + return false + } + for i := range t.Attrs { + if t.Attrs[i].Name == oldName { + t.Attrs[i].Name = newName + return true + } + } + return false +} + +type markdownIndentContext struct { + quoteDepth int + listContentIndents []int +} + +func (c *markdownIndentContext) isIndentedCodeLine(line string) bool { + line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r") + content, quoteDepth := stripMarkdownBlockQuotePrefixes(line) + if quoteDepth != c.quoteDepth { + c.quoteDepth = quoteDepth + c.listContentIndents = nil + } + if strings.TrimSpace(content) == "" { + return false + } + + indent, offset := markdownLeadingIndent(content) + for len(c.listContentIndents) > 0 && indent < c.listContentIndents[len(c.listContentIndents)-1] { + c.listContentIndents = c.listContentIndents[:len(c.listContentIndents)-1] + } + if indent <= 3 && isMarkdownThematicBreak(content[offset:]) { + return false + } + if markerIndent, ok := markdownListItemContentIndent(content[offset:], indent); ok && c.enterListItem(indent, markerIndent) { + return false + } + + containerIndent := 0 + if len(c.listContentIndents) > 0 { + containerIndent = c.listContentIndents[len(c.listContentIndents)-1] + } + return indent >= containerIndent+4 +} + +func (c *markdownIndentContext) enterListItem(indent, contentIndent int) bool { + for len(c.listContentIndents) > 0 { + parentIndent := c.listContentIndents[len(c.listContentIndents)-1] + if indent >= parentIndent && indent <= parentIndent+3 { + c.listContentIndents = append(c.listContentIndents, contentIndent) + return true + } + if indent < parentIndent { + c.listContentIndents = c.listContentIndents[:len(c.listContentIndents)-1] + continue + } + return false + } + if indent > 3 { + return false + } + c.listContentIndents = append(c.listContentIndents[:0], contentIndent) + return true +} + +func stripMarkdownBlockQuotePrefixes(line string) (string, int) { + depth := 0 + for { + indent := 0 + for indent < len(line) && indent < 3 && line[indent] == ' ' { + indent++ + } + if indent >= len(line) || line[indent] != '>' { + return line, depth + } + line = line[indent+1:] + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + line = line[1:] + } + depth++ + } +} + +func markdownLeadingIndent(line string) (columns, offset int) { + for offset < len(line) { + switch line[offset] { + case ' ': + columns++ + case '\t': + columns += 4 - columns%4 + default: + return columns, offset + } + offset++ + } + return columns, offset +} + +func markdownListItemContentIndent(line string, markerColumn int) (int, bool) { + markerWidth := 0 + if len(line) > 0 && (line[0] == '-' || line[0] == '+' || line[0] == '*') { + markerWidth = 1 + } else { + for markerWidth < len(line) && markerWidth < 9 && line[markerWidth] >= '0' && line[markerWidth] <= '9' { + markerWidth++ + } + if markerWidth == 0 || markerWidth >= len(line) || (line[markerWidth] != '.' && line[markerWidth] != ')') { + return 0, false + } + markerWidth++ + } + if markerWidth == len(line) { + return markerColumn + markerWidth + 1, true + } + if line[markerWidth] != ' ' && line[markerWidth] != '\t' { + return 0, false + } + + paddingColumns := 0 + column := markerColumn + markerWidth + for i := markerWidth; i < len(line); i++ { + switch line[i] { + case ' ': + column++ + paddingColumns++ + case '\t': + width := 4 - column%4 + column += width + paddingColumns += width + default: + if paddingColumns > 4 { + paddingColumns = 1 + } + return markerColumn + markerWidth + paddingColumns, true + } + } + if paddingColumns > 4 { + paddingColumns = 1 + } + return markerColumn + markerWidth + paddingColumns, true +} + +func isMarkdownThematicBreak(line string) bool { + marker := byte(0) + count := 0 + for i := 0; i < len(line); i++ { + switch line[i] { + case ' ', '\t': + continue + case '*', '-', '_': + if marker == 0 { + marker = line[i] + } else if line[i] != marker { + return false + } + count++ + default: + return false + } + } + return count >= 3 +} + +func findMarkdownRawHTMLInertEnd(content string, start int) (int, bool) { + if start < 0 || start >= len(content) || content[start] != '<' || start+2 >= len(content) { + return 0, false + } + for _, name := range []string{"pre", "script", "style", "textarea"} { + nameEnd := start + 1 + len(name) + if nameEnd >= len(content) || !strings.EqualFold(content[start+1:nameEnd], name) || !isLocalDocResourceHTMLTagBoundary(content[nameEnd]) { + continue + } + startTagEnd := findXMLStartTagEnd(content, start) + if startTagEnd < 0 { + return len(content), true + } + if strings.HasSuffix(strings.TrimSpace(content[start:startTagEnd]), "/>") { + return startTagEnd, true + } + + searchFrom := startTagEnd + lowerRest := strings.ToLower(content[searchFrom:]) + needle := "' || value == '/' || value == ' ' || value == '\t' || value == '\r' || value == '\n' +} + +func (t localDocResourceTag) render() string { + var out strings.Builder + out.WriteByte('<') + out.WriteString(t.Name) + for _, attr := range t.Attrs { + out.WriteByte(' ') + out.WriteString(attr.Name) + out.WriteString(`="`) + out.WriteString(escapeXMLAttr(attr.Value)) + out.WriteByte('"') + } + if t.SelfClosing { + out.WriteString("/>") + } else { + out.WriteByte('>') + } + return out.String() +} + +func parseLocalDocResourceTag(raw, expected string) (localDocResourceTag, error) { + if expected == "img" { + raw = escapeBareXMLAmpersandsInTagAttr(raw, "href") + } + decoder := xml.NewDecoder(strings.NewReader(raw)) + for { + token, err := decoder.Token() + if err != nil { + return localDocResourceTag{}, err + } + start, ok := token.(xml.StartElement) + if !ok { + continue + } + if start.Name.Local != expected { + return localDocResourceTag{}, fmt.Errorf("expected <%s>, got <%s>", expected, start.Name.Local) //nolint:forbidigo // caller wraps with typed validation error. + } + attrs := make([]html5BlockAttr, 0, len(start.Attr)) + seenAttrs := make(map[string]struct{}, len(start.Attr)) + for _, attr := range start.Attr { + attrName := strings.ToLower(strings.TrimSpace(attr.Name.Local)) + if _, exists := seenAttrs[attrName]; exists { + return localDocResourceTag{}, fmt.Errorf("duplicate attribute %q", attrName) //nolint:forbidigo // caller wraps with typed validation error. + } + seenAttrs[attrName] = struct{}{} + attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value}) + } + return localDocResourceTag{Name: expected, Attrs: attrs, SelfClosing: strings.HasSuffix(strings.TrimSpace(raw), "/>")}, nil + } +} + +func escapeBareXMLAmpersandsInTagAttr(raw, targetAttr string) string { + for i := 1; i < len(raw); { + for i < len(raw) && isXMLSpace(raw[i]) { + i++ + } + nameStart := i + for i < len(raw) && isLocalDocResourceAttrNameByte(raw[i]) { + i++ + } + if nameStart == i { + i++ + continue + } + name := raw[nameStart:i] + for i < len(raw) && isXMLSpace(raw[i]) { + i++ + } + if i >= len(raw) || raw[i] != '=' { + continue + } + i++ + for i < len(raw) && isXMLSpace(raw[i]) { + i++ + } + if i >= len(raw) || (raw[i] != '"' && raw[i] != '\'') { + continue + } + quote := raw[i] + valueStart := i + 1 + valueEnd := strings.IndexByte(raw[valueStart:], quote) + if valueEnd < 0 { + return raw + } + valueEnd += valueStart + if strings.EqualFold(name, targetAttr) { + value := escapeBareXMLAmpersands(raw[valueStart:valueEnd]) + return raw[:valueStart] + value + raw[valueEnd:] + } + i = valueEnd + 1 + } + return raw +} + +func isXMLSpace(value byte) bool { + return value == ' ' || value == '\t' || value == '\r' || value == '\n' +} + +func isLocalDocResourceAttrNameByte(value byte) bool { + return value > ' ' && value != '=' && value != '/' && value != '>' +} + +func escapeBareXMLAmpersands(value string) string { + var out strings.Builder + for i := 0; i < len(value); { + if value[i] != '&' { + out.WriteByte(value[i]) + i++ + continue + } + if entityLen := validXMLCharacterEntityLength(value[i:]); entityLen > 0 { + out.WriteString(value[i : i+entityLen]) + i += entityLen + continue + } + out.WriteString("&") + i++ + } + return out.String() +} + +func validXMLCharacterEntityLength(value string) int { + for _, entity := range []string{"&", "<", ">", """, "'"} { + if strings.HasPrefix(value, entity) { + return len(entity) + } + } + if !strings.HasPrefix(value, "&#") { + return 0 + } + i := 2 + isDigit := func(value byte) bool { return value >= '0' && value <= '9' } + if i < len(value) && (value[i] == 'x' || value[i] == 'X') { + i++ + isDigit = func(value byte) bool { + return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f') || (value >= 'A' && value <= 'F') + } + } + digitsStart := i + for i < len(value) && isDigit(value[i]) { + i++ + } + if i == digitsStart || i >= len(value) || value[i] != ';' { + return 0 + } + return i + 1 +} + +func localResourceTagNameAt(content string, index int) string { + for _, name := range []string{"img", "source"} { + prefix := "<" + name + if !strings.HasPrefix(content[index:], prefix) { + continue + } + next := index + len(prefix) + if next >= len(content) || content[next] == '>' || content[next] == '/' || content[next] == ' ' || content[next] == '\t' || content[next] == '\r' || content[next] == '\n' { + return name + } + } + return "" +} + +func findXMLStartTagEnd(content string, start int) int { + var quote byte + for i := start + 1; i < len(content); i++ { + if quote != 0 { + if content[i] == quote { + quote = 0 + } + continue + } + switch content[i] { + case '\'', '"': + quote = content[i] + case '>': + return i + 1 + } + } + return -1 +} + +type parsedMarkdownImage struct { + Alt string + Destination string + Title string + ReferenceLabel string + End int +} + +func parseMarkdownImageAt(content string, start int) (parsedMarkdownImage, bool) { + altEnd := findMarkdownClosingBracket(content, start+2) + if altEnd < 0 { + return parsedMarkdownImage{}, false + } + alt := content[start+2 : altEnd] + next := altEnd + 1 + if next < len(content) && content[next] == '(' { + destination, title, end, ok := parseMarkdownImageDestination(content, next) + if !ok { + return parsedMarkdownImage{}, false + } + return parsedMarkdownImage{Alt: alt, Destination: destination, Title: title, End: end}, true + } + if next < len(content) && content[next] == '[' { + labelEnd := findMarkdownClosingBracket(content, next+1) + if labelEnd < 0 { + return parsedMarkdownImage{}, false + } + label := content[next+1 : labelEnd] + if label == "" { + label = alt + } + return parsedMarkdownImage{Alt: alt, ReferenceLabel: label, End: labelEnd + 1}, true + } + return parsedMarkdownImage{Alt: alt, ReferenceLabel: alt, End: altEnd + 1}, true +} + +func parseMarkdownImageDestination(content string, open int) (string, string, int, bool) { + i := open + 1 + for i < len(content) && (content[i] == ' ' || content[i] == '\t') { + i++ + } + if i >= len(content) { + return "", "", 0, false + } + var destination string + if content[i] == '<' { + start := i + 1 + i++ + for i < len(content) { + if content[i] == '>' && !isEscapedMarkdownByte(content, i) { + destination = content[start:i] + i++ + break + } + i++ + } + if destination == "" { + return "", "", 0, false + } + } else { + start := i + depth := 0 + for i < len(content) { + if isEscapedMarkdownByte(content, i) { + i++ + continue + } + switch content[i] { + case '(': + depth++ + case ')': + if depth == 0 { + destination = content[start:i] + return unescapeMarkdownText(strings.TrimSpace(destination)), "", i + 1, destination != "" + } + depth-- + case ' ', '\t', '\r', '\n': + if depth == 0 { + destination = content[start:i] + goto findClose + } + } + i++ + } + return "", "", 0, false + } + +findClose: + for i < len(content) && isMarkdownSpace(content[i]) { + i++ + } + if i >= len(content) { + return "", "", 0, false + } + if content[i] == ')' { + return unescapeMarkdownText(strings.TrimSpace(destination)), "", i + 1, destination != "" + } + + opener := content[i] + closer := opener + if opener == '(' { + closer = ')' + } else if opener != '\'' && opener != '"' { + return "", "", 0, false + } + titleStart := i + 1 + i = titleStart + for i < len(content) { + if content[i] == closer && !isEscapedMarkdownByte(content, i) { + title := unescapeMarkdownText(content[titleStart:i]) + i++ + for i < len(content) && isMarkdownSpace(content[i]) { + i++ + } + if i >= len(content) || content[i] != ')' { + return "", "", 0, false + } + return unescapeMarkdownText(strings.TrimSpace(destination)), title, i + 1, destination != "" + } + i++ + } + return "", "", 0, false +} + +func isMarkdownSpace(char byte) bool { + return char == ' ' || char == '\t' || char == '\r' || char == '\n' +} + +func collectLocalMarkdownImageReferences(content string) map[string]struct{} { + refs := map[string]struct{}{} + var fenceChar byte + var fenceLen int + var indentCtx markdownIndentContext + for _, line := range strings.Split(maskLocalDocResourceMarkupInertContexts(content), "\n") { + indentedCode := false + if fenceChar == 0 { + indentedCode = indentCtx.isIndentedCodeLine(line) + } + char, run, isFence := markdownFence(line) + if fenceChar == 0 && isFence { + fenceChar, fenceLen = char, run + continue + } + if fenceChar != 0 { + if isFence && char == fenceChar && run >= fenceLen && markdownFenceCloses(line, char, run) { + fenceChar, fenceLen = 0, 0 + } + continue + } + if indentedCode { + continue + } + label, destination, ok := parseMarkdownReferenceDefinition(line) + if ok && strings.HasPrefix(destination, "@") { + refs[normalizeMarkdownReferenceLabel(label)] = struct{}{} + } + } + return refs +} + +func maskLocalDocResourceMarkupInertContexts(content string) string { + var out strings.Builder + for i := 0; i < len(content); { + if end, ok := findMarkdownRawHTMLInertEnd(content, i); ok { + writeLocalDocResourceMaskedSpan(&out, content[i:end]) + i = end + continue + } + terminator := "" + prefixLen := 0 + switch { + case strings.HasPrefix(content[i:], "" + prefixLen = 4 + case strings.HasPrefix(content[i:], "" + prefixLen = 9 + } + if terminator == "" { + out.WriteByte(content[i]) + i++ + continue + } + end := strings.Index(content[i+prefixLen:], terminator) + if end < 0 { + end = len(content) + } else { + end += i + prefixLen + len(terminator) + } + writeLocalDocResourceMaskedSpan(&out, content[i:end]) + i = end + } + return out.String() +} + +func writeLocalDocResourceMaskedSpan(out *strings.Builder, content string) { + for _, char := range content { + if char == '\n' || char == '\r' { + out.WriteRune(char) + } else { + out.WriteByte(' ') + } + } +} + +func parseMarkdownReferenceDefinition(line string) (string, string, bool) { + trimmed := strings.TrimLeft(line, " \t") + if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "[") { + return "", "", false + } + end := findMarkdownClosingBracket(trimmed, 1) + if end < 0 || end+1 >= len(trimmed) || trimmed[end+1] != ':' { + return "", "", false + } + rest := strings.TrimSpace(trimmed[end+2:]) + if strings.HasPrefix(rest, "<") { + close := strings.Index(rest, ">") + if close < 0 { + return "", "", false + } + return trimmed[1:end], rest[1:close], true + } + if field := strings.Fields(rest); len(field) > 0 { + return trimmed[1:end], unescapeMarkdownText(field[0]), true + } + return "", "", false +} + +func normalizeMarkdownReferenceLabel(label string) string { + return strings.ToLower(strings.Join(strings.Fields(unescapeMarkdownText(label)), " ")) +} + +func unescapeMarkdownText(value string) string { + var out strings.Builder + for i := 0; i < len(value); i++ { + if value[i] == '\\' && i+1 < len(value) && isMarkdownEscapablePunctuation(value[i+1]) { + i++ + } + out.WriteByte(value[i]) + } + return out.String() +} + +func isMarkdownEscapablePunctuation(value byte) bool { + return (value >= '!' && value <= '/') || (value >= ':' && value <= '@') || + (value >= '[' && value <= '`') || (value >= '{' && value <= '~') +} + +func findMarkdownClosingBracket(content string, start int) int { + depth := 0 + for i := start; i < len(content); i++ { + if isEscapedMarkdownByte(content, i) { + continue + } + switch content[i] { + case '[': + depth++ + case ']': + if depth == 0 { + return i + } + depth-- + } + } + return -1 +} + +func isEscapedMarkdownByte(content string, index int) bool { + backslashes := 0 + for i := index - 1; i >= 0 && content[i] == '\\'; i-- { + backslashes++ + } + return backslashes%2 == 1 +} + +func countByteRun(content string, start int, value byte) int { + i := start + for i < len(content) && content[i] == value { + i++ + } + return i - start +} + +func findMatchingBacktickRun(content string, start, run int) int { + for i := start; i < len(content); { + if content[i] != '`' { + i++ + continue + } + got := countByteRun(content, i, '`') + if got == run { + return i + got + } + i += got + } + return -1 +} + +func markdownFence(line string) (byte, int, bool) { + trimmed, ok := markdownFenceCandidate(line) + if !ok || len(trimmed) < 3 { + return 0, 0, false + } + char := trimmed[0] + if char != '`' && char != '~' { + return 0, 0, false + } + run := countByteRun(trimmed, 0, char) + return char, run, run >= 3 +} + +func markdownFenceCloses(line string, char byte, run int) bool { + trimmed, ok := markdownFenceCandidate(line) + return ok && len(trimmed) >= run && strings.TrimSpace(trimmed[run:]) == "" && trimmed[0] == char +} + +func markdownFenceCandidate(line string) (string, bool) { + rest := strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r") + for { + indent := 0 + for indent < len(rest) && rest[indent] == ' ' { + indent++ + } + if indent > 3 || (indent < len(rest) && rest[indent] == '\t') { + return "", false + } + rest = rest[indent:] + if strings.HasPrefix(rest, ">") { + rest = rest[1:] + if strings.HasPrefix(rest, " ") || strings.HasPrefix(rest, "\t") { + rest = rest[1:] + } + continue + } + if prefixLen := markdownListMarkerPrefixLen(rest); prefixLen > 0 { + rest = rest[prefixLen:] + continue + } + return rest, true + } +} + +func markdownListMarkerPrefixLen(line string) int { + if len(line) >= 2 && (line[0] == '-' || line[0] == '+' || line[0] == '*') && (line[1] == ' ' || line[1] == '\t') { + return 2 + } + digits := 0 + for digits < len(line) && digits < 9 && line[digits] >= '0' && line[digits] <= '9' { + digits++ + } + if digits == 0 || digits+1 >= len(line) || (line[digits] != '.' && line[digits] != ')') || (line[digits+1] != ' ' && line[digits+1] != '\t') { + return 0 + } + return digits + 2 +} diff --git a/shortcuts/doc/local_doc_resources_test.go b/shortcuts/doc/local_doc_resources_test.go new file mode 100644 index 0000000000..8c09dfd310 --- /dev/null +++ b/shortcuts/doc/local_doc_resources_test.go @@ -0,0 +1,2080 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "image" + "image/png" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/httpmock" + internaltransport "github.com/larksuite/cli/internal/transport" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestPrepareLocalDocResourcesXMLImageAndSource(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, map[string]string{ + "diagram.png": localDocResourcePNG(t, 100, 80), + "report.pdf": "pdf-data", + }) + content := `

before

diagram` + + got, resources, err := prepareLocalDocResources(runtime, "xml", content) + if err != nil { + t.Fatalf("prepareLocalDocResources() error: %v", err) + } + if len(resources) != 2 { + t.Fatalf("resources len = %d, want 2: %#v", len(resources), resources) + } + if resources[0].Kind != localDocResourceImage || resources[0].Path != "diagram.png" { + t.Fatalf("image resource = %#v", resources[0]) + } + if resources[1].Kind != localDocResourceFile || resources[1].Path != "report.pdf" { + t.Fatalf("file resource = %#v", resources[1]) + } + if strings.Contains(got, "@diagram.png") || strings.Contains(got, "@report.pdf") { + t.Fatalf("rewritten content leaks local path: %s", got) + } + for _, want := range []string{resources[0].Marker, resources[1].Marker, `width="100"`, `height="80"`, `align="right"`, `scale="0.500000"`} { + if !strings.Contains(got, want) { + t.Fatalf("rewritten content missing %q: %s", want, got) + } + } +} + +func TestPrepareLocalDocResourcesRejectsUndecodableLocalImage(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, map[string]string{"fake.png": "not an image"}) + _, _, err := prepareLocalDocResources(runtime, "xml", ``) + 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 != "path" { + t.Fatalf("error=%T %v problem=%#v validation=%#v", err, err, problem, validationErr) + } +} + +func TestPrepareLocalDocResourcesXMLRemoteImage(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, nil) + got, resources, err := prepareLocalDocResources(runtime, "xml", `

before

remote`) + if err != nil { + t.Fatalf("prepareLocalDocResources() error: %v", err) + } + if len(resources) != 1 { + t.Fatalf("resources = %d, want 1", len(resources)) + } + resource := resources[0] + if resource.RemoteURL != "http://93.184.216.34/photo.png" { + t.Fatalf("RemoteURL = %q", resource.RemoteURL) + } + if resource.Path != "" || resource.Size != 0 { + t.Fatalf("remote resource materialized during preparation: %#v", resource) + } + if strings.Contains(got, `href=`) || !strings.Contains(got, `path="`+resource.Marker+`"`) || !strings.Contains(got, `caption="remote"`) { + t.Fatalf("prepared content = %q", got) + } +} + +func TestPrepareLocalDocResourcesXMLRemoteImageAcceptsBareAmpersandsInHref(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, nil) + content := `` + got, resources, err := prepareLocalDocResources(runtime, "xml", content) + if err != nil { + t.Fatalf("prepareLocalDocResources() error: %v", err) + } + if len(resources) != 1 { + t.Fatalf("resources = %d, want 1", len(resources)) + } + if want := "https://93.184.216.34/photo.png?x=1&image_size=large&token=a%26b"; resources[0].RemoteURL != want { + t.Fatalf("RemoteURL = %q, want %q", resources[0].RemoteURL, want) + } + if !strings.Contains(got, `path="`+resources[0].Marker+`"`) || strings.Contains(got, `href=`) { + t.Fatalf("prepared content = %q", got) + } +} + +func TestPrepareLocalDocResourcesXMLRemoteImagePreservesValidEntities(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, nil) + got, resources, err := prepareLocalDocResources( + runtime, + "xml", + ``, + ) + if err != nil { + t.Fatalf("prepareLocalDocResources() error: %v", err) + } + if len(resources) != 1 { + t.Fatalf("resources = %d, want 1", len(resources)) + } + if want := "https://93.184.216.34/photo.png?x=1&y=2&z=3"; resources[0].RemoteURL != want { + t.Fatalf("RemoteURL = %q, want %q", resources[0].RemoteURL, want) + } + if !strings.Contains(got, `caption="A & B"`) { + t.Fatalf("prepared content = %q", got) + } +} + +func TestPrepareLocalDocResourcesXMLRemoteImageDoesNotRelaxOtherAttributes(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, nil) + _, _, err := prepareLocalDocResources(runtime, "xml", ``) + if err == nil { + t.Fatal("prepareLocalDocResources() error = nil") + } + if !strings.Contains(err.Error(), "invalid character entity") { + t.Fatalf("prepareLocalDocResources() error = %v", err) + } +} + +func TestPrepareLocalDocResourcesRemoteImageRejectsConflicts(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, nil) + for _, content := range []string{ + ``, + ``, + ``, + } { + if _, _, err := prepareLocalDocResources(runtime, "xml", content); err == nil { + t.Fatalf("prepareLocalDocResources(%q) error = nil", content) + } + } +} + +func TestUploadRemoteDocImagesRecordsIndividualDownloadFailure(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("remote-image-partial-download")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("remote-image-partial-download"), factory, core.AsUser) + _, resources, err := prepareLocalDocResources( + runtime, + "xml", + ``, + ) + if err != nil { + t.Fatalf("prepareLocalDocResources: %v", err) + } + + originalDownload := downloadRemoteDocImage + t.Cleanup(func() { downloadRemoteDocImage = originalDownload }) + var downloadMu sync.Mutex + downloadCalls := make(map[int]int) + downloadRemoteDocImage = func(_ *common.RuntimeContext, _ string, occurrence int) (remoteDocImageDownload, error) { + downloadMu.Lock() + downloadCalls[occurrence]++ + downloadMu.Unlock() + if occurrence == 2 { + return remoteDocImageDownload{}, errors.New("second download failed") + } + payload := []byte(localDocResourcePNG(t, 20, 10)) + return remoteDocImageDownload{Content: payload, FileName: "image.png", Width: 20, Height: 10}, nil + } + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "file_one"}}, + }) + outcomes := []*localDocResourceOutcome{ + {Resource: resources[0], BlockID: "blk_one", Status: "pending"}, + {Resource: resources[1], BlockID: "blk_two", Status: "pending"}, + } + uploadLocalDocResources(runtime, "doxcn_partial_download", outcomes) + if outcomes[0].Status != "uploaded" || outcomes[0].FileToken != "file_one" { + t.Fatalf("first outcome = %#v", outcomes[0]) + } + if outcomes[1].Status != "upload_failed" || outcomes[1].Err == nil || !strings.Contains(outcomes[1].Err.Error(), "second download failed") { + t.Fatalf("second outcome = %#v", outcomes[1]) + } + downloadMu.Lock() + secondDownloadCalls := downloadCalls[2] + downloadMu.Unlock() + if secondDownloadCalls != 1 { + t.Fatalf("non-retryable second download calls = %d, want 1", secondDownloadCalls) + } +} + +func TestUploadRemoteDocImageRetriesRetryableDownload(t *testing.T) { + factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("remote-image-download-retry")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("remote-image-download-retry"), factory, core.AsUser) + payload := []byte(localDocResourcePNG(t, 20, 10)) + originalDownload := downloadRemoteDocImage + downloadCalls := 0 + downloadRemoteDocImage = func(_ *common.RuntimeContext, _ string, _ int) (remoteDocImageDownload, error) { + downloadCalls++ + if downloadCalls < remoteDocImageDownloadAttempts { + return remoteDocImageDownload{}, errs.NewNetworkError(errs.SubtypeNetworkTransport, "temporary download failure").WithRetryable() + } + return remoteDocImageDownload{Content: payload, FileName: "image.png", Width: 20, Height: 10}, nil + } + t.Cleanup(func() { downloadRemoteDocImage = originalDownload }) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "file_after_download_retry"}}, + }) + var waits []time.Duration + originalWait := waitLocalDocResourceRequest + waitLocalDocResourceRequest = func(_ context.Context, delay time.Duration) error { + waits = append(waits, delay) + return nil + } + t.Cleanup(func() { waitLocalDocResourceRequest = originalWait }) + + outcome := &localDocResourceOutcome{ + Resource: localDocResource{Occurrence: 1, Kind: localDocResourceImage, RemoteURL: "https://93.184.216.34/retry.png"}, + BlockID: "blk_download_retry", + Status: "pending", + } + uploadLocalDocResources(runtime, "doxcn_download_retry", []*localDocResourceOutcome{outcome}) + if outcome.Status != "uploaded" || outcome.FileToken != "file_after_download_retry" { + t.Fatalf("outcome = %#v", outcome) + } + if downloadCalls != remoteDocImageDownloadAttempts { + t.Fatalf("download calls = %d, want %d", downloadCalls, remoteDocImageDownloadAttempts) + } + assertLocalDocResourceRetryWaits(t, waits, 2) +} + +func TestUploadRemoteDocImageRetriesRetryableUploadWithoutRedownloading(t *testing.T) { + factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("remote-image-upload-retry")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("remote-image-upload-retry"), factory, core.AsUser) + payload := []byte(localDocResourcePNG(t, 20, 10)) + originalDownload := downloadRemoteDocImage + downloadCalls := 0 + downloadRemoteDocImage = func(_ *common.RuntimeContext, _ string, _ int) (remoteDocImageDownload, error) { + downloadCalls++ + return remoteDocImageDownload{Content: payload, FileName: "image.png", Width: 20, Height: 10}, nil + } + t.Cleanup(func() { downloadRemoteDocImage = originalDownload }) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Status: http.StatusServiceUnavailable, + Body: map[string]interface{}{"code": 0, "msg": "temporary upstream failure"}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "file_after_upload_retry"}}, + }) + var waits []time.Duration + originalWait := waitLocalDocResourceRequest + waitLocalDocResourceRequest = func(_ context.Context, delay time.Duration) error { + waits = append(waits, delay) + return nil + } + t.Cleanup(func() { waitLocalDocResourceRequest = originalWait }) + + outcome := &localDocResourceOutcome{ + Resource: localDocResource{Occurrence: 1, Kind: localDocResourceImage, RemoteURL: "https://93.184.216.34/retry.png"}, + BlockID: "blk_upload_retry", + Status: "pending", + } + uploadLocalDocResources(runtime, "doxcn_upload_retry", []*localDocResourceOutcome{outcome}) + if outcome.Status != "uploaded" || outcome.FileToken != "file_after_upload_retry" { + t.Fatalf("outcome = %#v", outcome) + } + if downloadCalls != 1 { + t.Fatalf("download calls = %d, want 1 when only upload is retried", downloadCalls) + } + assertLocalDocResourceRetryWaits(t, waits, 1) +} + +func TestApplyRemoteDocImageDownloadNormalizesPresentationWithoutRetainingContent(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, nil) + content, resources, err := prepareLocalDocResources(runtime, "xml", ``) + if err != nil { + t.Fatalf("prepareLocalDocResources: %v", err) + } + payload := []byte(localDocResourcePNG(t, 200, 100)) + resource := resources[0] + if err := applyRemoteDocImageDownload(&resource, remoteDocImageDownload{Content: payload, FileName: "image.png", Width: 200, Height: 100}); err != nil { + t.Fatalf("applyRemoteDocImageDownload: %v", err) + } + if resource.ImageWidth != 200 || resource.ImageHeight != 100 || !resource.HasScale || resource.ImageScale != 0.25 { + t.Fatalf("downloaded resource = %#v", resource) + } + if resource.FileName != "image.png" || resource.Size != int64(len(payload)) || len(resource.Content) != 0 { + t.Fatalf("downloaded resource metadata = %#v", resource) + } + if !strings.Contains(content, resource.Marker) || strings.Contains(content, `width=`) || strings.Contains(content, `align=`) { + t.Fatalf("placeholder content = %q, want presentation deferred until upload", content) + } +} + +func TestDownloadRemoteDocImageContentBuffersSupportedImage(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, nil) + payload := []byte(localDocResourcePNG(t, 30, 20)) + + originalDo := doRemoteDocImageRequest + t.Cleanup(func() { doRemoteDocImageRequest = originalDo }) + doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"image/png"}}, + Body: io.NopCloser(bytes.NewReader(payload)), + ContentLength: int64(len(payload)), + Request: req, + }, nil + } + + download, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/photo.png", 1) + if err != nil { + t.Fatalf("downloadRemoteDocImageContent: %v", err) + } + if download.FileName != "image.png" || download.Width != 30 || download.Height != 20 || !bytes.Equal(download.Content, payload) { + t.Fatalf("download result = %#v", download) + } +} + +type remoteDocImageProbeBody struct { + reads int + closed bool +} + +func (b *remoteDocImageProbeBody) Read([]byte) (int, error) { + b.reads++ + return 0, io.EOF +} + +func (b *remoteDocImageProbeBody) Close() error { + b.closed = true + return nil +} + +func TestProbeRemoteDocImageDownloadDoesNotReadImageBody(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, nil) + originalDo := doRemoteDocImageRequest + t.Cleanup(func() { doRemoteDocImageRequest = originalDo }) + body := &remoteDocImageProbeBody{} + method := "" + requestRange := "" + doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) { + method = req.Method + requestRange = req.Header.Get("Range") + return &http.Response{ + StatusCode: http.StatusOK, + ContentLength: 1024, + Header: http.Header{"Content-Type": []string{"image/png"}}, + Body: body, + }, nil + } + + if err := probeRemoteDocImageDownload(runtime, "https://93.184.216.34/image.png", 1); err != nil { + t.Fatalf("probeRemoteDocImageDownload: %v", err) + } + if method != http.MethodGet || requestRange != "bytes=0-0" || body.reads != 0 || !body.closed { + t.Fatalf("probe method=%q range=%q reads=%d closed=%v, want ranged GET with closed unread body", method, requestRange, body.reads, body.closed) + } +} + +func TestDownloadRemoteDocImageContentRejectsNonImage(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, nil) + + originalDo := doRemoteDocImageRequest + t.Cleanup(func() { doRemoteDocImageRequest = originalDo }) + doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/plain"}}, + Body: io.NopCloser(strings.NewReader("not an image")), + Request: req, + }, nil + } + + _, err := downloadRemoteDocImageContent(runtime, "http://93.184.216.34/not-image", 1) + if err == nil { + t.Fatal("downloadRemoteDocImageContent() error = nil") + } + 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 != "href" { + t.Fatalf("problem = %#v, validation = %#v, %v", problem, validationErr, ok) + } +} + +func TestDownloadRemoteDocImageContentRejectsDeclaredOversize(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, nil) + + originalDo := doRemoteDocImageRequest + t.Cleanup(func() { doRemoteDocImageRequest = originalDo }) + doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"image/png"}}, + Body: io.NopCloser(strings.NewReader("")), + ContentLength: remoteDocImageMaxBytes + 1, + Request: req, + }, nil + } + + _, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/oversize.png", 1) + if err == nil || !strings.Contains(err.Error(), "20MiB") { + t.Fatalf("downloadRemoteDocImageContent() error = %v", err) + } + 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 != "href" { + t.Fatalf("problem = %#v, validation = %#v, ok = %v", problem, validationErr, ok) + } +} + +func TestDownloadRemoteDocImageHTTPStatusRetryMetadata(t *testing.T) { + tests := []struct { + status int + wantSubtype errs.Subtype + wantRetryable bool + }{ + {status: http.StatusTooManyRequests, wantSubtype: errs.SubtypeRateLimit, wantRetryable: true}, + {status: http.StatusServiceUnavailable, wantSubtype: errs.SubtypeNetworkServer, wantRetryable: true}, + {status: http.StatusNotFound, wantSubtype: errs.SubtypeNetworkTransport}, + } + for _, test := range tests { + t.Run(http.StatusText(test.status), func(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, nil) + + originalDo := doRemoteDocImageRequest + t.Cleanup(func() { doRemoteDocImageRequest = originalDo }) + doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: test.status, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("temporary")), + Request: req, + }, nil + } + + _, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/image.png", 1) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != test.wantSubtype || + problem.Code != test.status || problem.Retryable != test.wantRetryable { + t.Fatalf("error=%T %v problem=%#v", err, err, problem) + } + }) + } +} + +type cannedRemoteImageTransport struct { + base http.RoundTripper + payload []byte +} + +func (t *cannedRemoteImageTransport) BaseRoundTripper() http.RoundTripper { + return t.base +} + +func (t *cannedRemoteImageTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper { + return &cannedRemoteImageTransport{base: base, payload: t.payload} +} + +func (t *cannedRemoteImageTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"image/png"}}, + Body: io.NopCloser(bytes.NewReader(t.payload)), + ContentLength: int64(len(t.payload)), + Request: req, + }, nil +} + +func TestDownloadRemoteDocImageContentUsesExternalPolicyBranch(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, nil) + runtime.Factory.HttpClient = func() (*http.Client, error) { + external := &cannedRemoteImageTransport{base: http.DefaultTransport, payload: []byte(localDocResourcePNG(t, 2, 1))} + return &http.Client{Transport: internaltransport.NewHTTPPolicyRouter(http.DefaultTransport, external)}, nil + } + download, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/image.png", 1) + if err != nil { + t.Fatalf("downloadRemoteDocImageContent() error = %v", err) + } + if download.FileName != "image.png" || len(download.Content) <= 3 { + t.Fatalf("download result = %#v", download) + } +} + +func TestDownloadRemoteDocImageContentRejectsInvalidImageBody(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, nil) + + originalDo := doRemoteDocImageRequest + t.Cleanup(func() { doRemoteDocImageRequest = originalDo }) + doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"image/png"}}, + Body: io.NopCloser(strings.NewReader("not an image")), + ContentLength: 25, + Request: req, + }, nil + } + + _, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/not-really.png", 1) + 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 != "href" { + t.Fatalf("error = %T %v, problem=%#v validation=%#v", err, err, problem, validationErr) + } +} + +func TestDownloadRemoteDocImageContentSupportsWebPDimensions(t *testing.T) { + const encodedWebP = "UklGRrIBAABXRUJQVlA4TKUBAAAvSsAYAA8w//M///MfeJAkbXvaSG7m8Q3GfYSBJekwQztm/IcZlgwnmWImn2BK7aFmBtnVir6q//8VOkFE/xm4baTIu8c48ArEo6+B3zFKYln3pqClSCKX0begFTAXFOLXHSyF8cCNcZEG4OywuA4KVVfJCiArU7GAgJI8+lJP/OKMT/fBAjevg1cYB7YVkFuWga2lyPi5I0HFy5YTpWIHg0RZpkniRVW9odHAKOwosWuOGdxIyn2OvaCDvhg/we6TwadPBPbqBV58MsLmMJ8yZnOWk8SRz4N+QoyPL+MnamzMvcE1rHNEr91F9GKZPVUcS9w7PhhH36suB9qPeYb/oLk6cuTiJ0wOK3m5h1cKjW6EVZCYMK7dxcKCBdgP9HkKr9gkAO2P8GKZGWVdIAatQa+1IDpt6qyorVwdy01xdW8Jkfk6xjEXmVQQ+HQdFr6OKhIN34dXWq0+0qr6EJSCeeVLH9+gvGTLyqM65PQ44ihzlTXxQKjKbAvshXgir7Lil9w4L2bvMycmjQcqXaMCO6BlY28i+FOLzbfI1vEqxAhotocAAA==" + payload, err := base64.StdEncoding.DecodeString(encodedWebP) + if err != nil { + t.Fatalf("decode WebP fixture: %v", err) + } + runtime := newLocalDocResourceTestRuntime(t, nil) + + originalDo := doRemoteDocImageRequest + t.Cleanup(func() { doRemoteDocImageRequest = originalDo }) + doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"image/webp"}}, + Body: io.NopCloser(bytes.NewReader(payload)), + ContentLength: int64(len(payload)), + Request: req, + }, nil + } + + download, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/image.webp", 1) + if err != nil { + t.Fatalf("downloadRemoteDocImageContent() error = %v", err) + } + if download.Width <= 0 || download.Height <= 0 || download.FileName != "image.webp" || !bytes.Equal(download.Content, payload) { + t.Fatalf("WebP download = %#v", download) + } +} + +func TestDownloadRemoteDocImageContentSupportsBMPAndTIFFDimensions(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, nil) + + originalDo := doRemoteDocImageRequest + t.Cleanup(func() { doRemoteDocImageRequest = originalDo }) + + tests := []struct { + name string + contentType string + payload []byte + width int + height int + ext string + }{ + {name: "BMP", contentType: "image/bmp", payload: testBMPImage(2, 3), width: 2, height: 3, ext: ".bmp"}, + {name: "TIFF", contentType: "image/tiff", payload: testTIFFImage(4, 5), width: 4, height: 5, ext: ".tiff"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{tt.contentType}}, + Body: io.NopCloser(bytes.NewReader(tt.payload)), + ContentLength: int64(len(tt.payload)), + Request: req, + }, nil + } + + download, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/image"+tt.ext, 1) + if err != nil { + t.Fatalf("downloadRemoteDocImageContent() error = %v", err) + } + if download.Width != tt.width || download.Height != tt.height || download.FileName != "image"+tt.ext || !bytes.Equal(download.Content, tt.payload) { + t.Fatalf("download = %#v", download) + } + }) + } +} + +type remoteImageErrorReader struct{ err error } + +func (r remoteImageErrorReader) Read([]byte) (int, error) { return 0, r.err } + +func TestDownloadRemoteDocImageContentClassifiesResponseReadFailureAsNetwork(t *testing.T) { + readErr := errors.New("connection reset while streaming") + runtime := newLocalDocResourceTestRuntime(t, nil) + + originalDo := doRemoteDocImageRequest + t.Cleanup(func() { doRemoteDocImageRequest = originalDo }) + doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"image/png"}}, + Body: io.NopCloser(remoteImageErrorReader{err: readErr}), + ContentLength: -1, + Request: req, + }, nil + } + + _, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/image.png", 1) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport || !problem.Retryable || !errors.Is(err, readErr) { + t.Fatalf("error = %T %v, problem=%#v", err, err, problem) + } + var validationErr *errs.ValidationError + if errors.As(err, &validationErr) { + t.Fatalf("response read failure was classified as validation: %#v", validationErr) + } +} + +func TestRemoteDocImageNetworkErrorDoesNotRetryCanceledContext(t *testing.T) { + err := remoteDocImageNetworkError(context.Canceled, 1, "was interrupted") + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryNetwork || problem.Retryable || !errors.Is(err, context.Canceled) { + t.Fatalf("error = %T %v, problem=%#v", err, err, problem) + } +} + +func TestRemoteImageErrorsAndDryRunRedactURLCredentials(t *testing.T) { + const rawURL = "https://alice-sensitive:password@93.184.216.34/image.png?token=secret-value#frag-sensitive" + requestErr := &url.Error{Op: "Get", URL: rawURL, Err: errors.New("dial failed")} + got := remoteDocImageNetworkError(requestErr, 1, "request failed") + for _, secret := range []string{"alice-sensitive", "password", "secret-value", "frag-sensitive"} { + if strings.Contains(got.Error(), secret) { + t.Errorf("network error leaks %q: %v", secret, got) + } + } + + dry := appendRemoteDocImageDownloadsDryRun(common.NewDryRunAPI(), []localDocResource{{Occurrence: 1, RemoteURL: rawURL}}) + raw, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + for _, secret := range []string{"alice-sensitive", "password", "secret-value", "frag-sensitive"} { + if strings.Contains(string(raw), secret) { + t.Errorf("dry run leaks %q: %s", secret, raw) + } + } + if !strings.Contains(string(raw), `https://93.184.216.34/image.png`) { + t.Fatalf("dry run lost redacted endpoint identity: %s", raw) + } +} + +func TestPrepareLocalDocResourcesNormalizesImageDimensionsAndScale(t *testing.T) { + tests := []struct { + name string + nativeWidth int + nativeHeight int + attrs string + wantWidth int + wantHeight int + wantScale float64 + wantHasScale bool + wantScaleText string + }{ + { + name: "intrinsic dimensions without model display size", + nativeWidth: 100, + nativeHeight: 80, + wantWidth: 100, + wantHeight: 80, + }, + { + name: "model width becomes scale", + nativeWidth: 100, + nativeHeight: 80, + attrs: ` width="50"`, + wantWidth: 100, + wantHeight: 80, + wantScale: 0.5, + wantHasScale: true, + wantScaleText: `scale="0.500000"`, + }, + { + name: "model height becomes scale", + nativeWidth: 100, + nativeHeight: 80, + attrs: ` height="20"`, + wantWidth: 100, + wantHeight: 80, + wantScale: 0.25, + wantHasScale: true, + wantScaleText: `scale="0.250000"`, + }, + { + name: "model width wins when both dimensions exist", + nativeWidth: 100, + nativeHeight: 80, + attrs: ` width="25" height="70"`, + wantWidth: 100, + wantHeight: 80, + wantScale: 0.25, + wantHasScale: true, + wantScaleText: `scale="0.250000"`, + }, + { + name: "explicit scale wins over model dimensions", + nativeWidth: 100, + nativeHeight: 80, + attrs: ` width="50" scale="0.75"`, + wantWidth: 100, + wantHeight: 80, + wantScale: 0.75, + wantHasScale: true, + wantScaleText: `scale="0.750000"`, + }, + { + name: "percentage width becomes scale", + nativeWidth: 100, + nativeHeight: 80, + attrs: ` width="80%"`, + wantWidth: 100, + wantHeight: 80, + wantScale: 0.8, + wantHasScale: true, + wantScaleText: `scale="0.800000"`, + }, + { + name: "invalid model dimensions are ignored", + nativeWidth: 100, + nativeHeight: 80, + attrs: ` width="invalid" height="0" scale="-1"`, + wantWidth: 100, + wantHeight: 80, + }, + { + name: "wide image is capped below page width", + nativeWidth: 1200, + nativeHeight: 800, + wantWidth: 1200, + wantHeight: 800, + wantScale: 0.849999, + wantHasScale: true, + wantScaleText: `scale="0.849999"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, map[string]string{ + "diagram.png": localDocResourcePNG(t, tt.nativeWidth, tt.nativeHeight), + }) + content := `` + + got, resources, err := prepareLocalDocResources(runtime, "xml", content) + if err != nil { + t.Fatalf("prepareLocalDocResources() error: %v", err) + } + if len(resources) != 1 { + t.Fatalf("resources len = %d, want 1: %#v", len(resources), resources) + } + resource := resources[0] + if resource.ImageWidth != tt.wantWidth || resource.ImageHeight != tt.wantHeight { + t.Fatalf("intrinsic dimensions = %dx%d, want %dx%d; content=%s", resource.ImageWidth, resource.ImageHeight, tt.wantWidth, tt.wantHeight, got) + } + if resource.HasScale != tt.wantHasScale || resource.ImageScale != tt.wantScale { + t.Fatalf("scale = %v (present=%v), want %v (present=%v); content=%s", resource.ImageScale, resource.HasScale, tt.wantScale, tt.wantHasScale, got) + } + for _, want := range []string{ + fmt.Sprintf(`width="%d"`, tt.wantWidth), + fmt.Sprintf(`height="%d"`, tt.wantHeight), + } { + if !strings.Contains(got, want) { + t.Fatalf("rewritten content missing %q: %s", want, got) + } + } + if tt.wantHasScale { + if !strings.Contains(got, tt.wantScaleText) { + t.Fatalf("rewritten content missing %q: %s", tt.wantScaleText, got) + } + } else if strings.Contains(got, ` scale=`) { + t.Fatalf("rewritten content unexpectedly contains scale: %s", got) + } + }) + } +} + +// BUG_MAP #1: Markdown alt is persisted by docx_engine through caption, then +// exported back as Markdown alt. Sending only the SDK-only alt attribute loses +// the text when the placeholder reaches the engine. +func TestPrepareLocalDocResourcesMarkdownAltUsesCaption(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)}) + + got, resources, err := prepareLocalDocResources(runtime, "markdown", `![architecture diagram](@diagram.png)`) + if err != nil { + t.Fatalf("prepareLocalDocResources() error: %v", err) + } + if len(resources) != 1 { + t.Fatalf("resources len = %d, want 1", len(resources)) + } + if !strings.Contains(got, `caption="architecture diagram"`) { + t.Fatalf("Markdown alt must be mapped to engine caption, got: %s", got) + } + if strings.Contains(got, ` alt=`) { + t.Fatalf("rewritten Markdown image must not rely on non-persisted alt: %s", got) + } +} + +func TestPrepareLocalDocResourcesMarkdownTitleConsumesQuotedClosingParen(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)}) + content := `before ![architecture diagram](@diagram.png "v2 (final)") after` + + got, resources, err := prepareLocalDocResources(runtime, "markdown", content) + if err != nil { + t.Fatalf("prepareLocalDocResources() error: %v", err) + } + if len(resources) != 1 { + t.Fatalf("resources len = %d, want 1", len(resources)) + } + want := `before after` + if got != want { + t.Fatalf("rewritten Markdown image = %q, want %q", got, want) + } +} + +func TestParseMarkdownImageDestinationRejectsUnclosedTitle(t *testing.T) { + content := `![diagram](@diagram.png "v2 (final)) trailing` + if image, ok := parseMarkdownImageAt(content, 0); ok { + t.Fatalf("parseMarkdownImageAt() = %#v, true; want invalid unclosed title", image) + } +} + +// BUG_MAP #2: image-looking text in inert Markdown contexts must remain text; +// otherwise the CLI plans an upload for a block the Markdown parser never +// creates and reports a partial failure after the document write succeeded. +func TestPrepareLocalDocResourcesMarkdownIgnoresInertContexts(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)}) + content := "\n ![indented code](@diagram.png)\n" + + got, resources, err := prepareLocalDocResources(runtime, "markdown", content) + if err != nil { + t.Fatalf("prepareLocalDocResources() error: %v", err) + } + if got != content { + t.Fatalf("inert Markdown was rewritten:\n got: %q\nwant: %q", got, content) + } + if len(resources) != 0 { + t.Fatalf("inert Markdown planned %d resources, want 0: %#v", len(resources), resources) + } +} + +func TestPrepareLocalDocResourcesMarkdownListIndentIsRelativeToList(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)}) + content := "- report:\n\n ![chart](@diagram.png)\n" + + got, resources, err := prepareLocalDocResources(runtime, "markdown", content) + if err != nil { + t.Fatalf("prepareLocalDocResources() error: %v", err) + } + if len(resources) != 1 || !strings.Contains(got, resources[0].Marker) { + t.Fatalf("four-space list content was not rewritten: got=%q resources=%#v", got, resources) + } + if strings.Contains(got, "@diagram.png") { + t.Fatalf("rewritten list content leaked local path: %q", got) + } +} + +func TestPrepareLocalDocResourcesMarkdownListIndentedCodeRemainsInert(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)}) + content := "- report:\n\n ![code](@diagram.png)\n" + + got, resources, err := prepareLocalDocResources(runtime, "markdown", content) + if err != nil { + t.Fatalf("prepareLocalDocResources() error: %v", err) + } + if got != content || len(resources) != 0 { + t.Fatalf("six-space list code was rewritten: got=%q resources=%#v", got, resources) + } +} + +func TestPrepareLocalDocResourcesMarkdownThematicBreakDoesNotOpenList(t *testing.T) { + for _, thematicBreak := range []string{"* * *", "- - -"} { + t.Run(thematicBreak, func(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)}) + content := thematicBreak + "\n\n ![code](@diagram.png)\n" + + got, resources, err := prepareLocalDocResources(runtime, "markdown", content) + if err != nil { + t.Fatalf("prepareLocalDocResources() error: %v", err) + } + if got != content || len(resources) != 0 { + t.Fatalf("indented code after thematic break was rewritten: got=%q resources=%#v", got, resources) + } + }) + } +} + +// BUG_MAP #3: internal correlation markers are an implementation detail and +// must never escape in a partial-failure result, even if no document ID was +// returned and cleanup therefore cannot run. +func TestFinalizeLocalDocResourcesScrubsMarkerWhenDocumentIDMissing(t *testing.T) { + marker := "@lcli_img_0123456789abcdef0123456789abcdef" + factory, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-resource-marker-scrub")) + runtime := common.TestNewRuntimeContextForAPI( + context.Background(), + &cobra.Command{Use: "docs +create"}, + docsTestConfigWithAppID("local-resource-marker-scrub"), + factory, + core.AsUser, + ) + block := map[string]interface{}{ + "block_id": "blk_placeholder", + "block_type": "image", + "block_token": marker, + } + data := map[string]interface{}{ + "document": map[string]interface{}{ + "new_blocks": []interface{}{block}, + }, + } + + err := finalizeLocalDocResources(runtime, "", data, []localDocResource{{ + Occurrence: 1, + Kind: localDocResourceImage, + Marker: marker, + }}) + if err == nil { + t.Fatal("finalizeLocalDocResources() error = nil, want partial failure") + } + if token := common.GetString(block, "block_token"); token != "" { + t.Fatalf("partial-failure response leaked marker %q", token) + } + var envelope struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal partial-failure response: %v\nstdout: %s", err, stdout.String()) + } + if envelope.OK { + t.Fatalf("partial failure reported ok:true: %s", stdout.String()) + } + for _, internalField := range []string{"summary", "items"} { + if _, exposed := envelope.Data[internalField]; exposed { + t.Fatalf("partial-failure response exposed internal field %q: %s", internalField, stdout.String()) + } + } +} + +func TestNewLocalDocResourceRejectsTraversalAndSymlinkEscape(t *testing.T) { + root := t.TempDir() + work := filepath.Join(root, "work") + outside := filepath.Join(root, "outside") + if err := os.MkdirAll(work, 0o700); err != nil { + t.Fatalf("MkdirAll(work): %v", err) + } + if err := os.MkdirAll(outside, 0o700); err != nil { + t.Fatalf("MkdirAll(outside): %v", err) + } + if err := os.WriteFile(filepath.Join(outside, "secret.png"), []byte("secret"), 0o600); err != nil { + t.Fatalf("WriteFile(secret): %v", err) + } + if err := os.Symlink(filepath.Join(outside, "secret.png"), filepath.Join(work, "escape.png")); err != nil { + t.Fatalf("Symlink: %v", err) + } + cmdutil.TestChdir(t, work) + factory, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-resource-path")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-resource-path"), factory, core.AsUser) + + for _, path := range []string{"@../outside/secret.png", "@escape.png"} { + if _, err := newLocalDocResource(runtime, localDocResourceImage, path, 1); err == nil { + t.Fatalf("newLocalDocResource(%q) error = nil, want unsafe path rejection", path) + } + } +} + +func TestCorrelateLocalDocResourcesMatchesTypeAndBlockID(t *testing.T) { + marker := "@lcli_file_0123456789abcdef0123456789abcdef" + block := map[string]interface{}{ + "block_id": "blk_file", + "block_type": "file", + "block_token": marker, + } + data := map[string]interface{}{ + "document": map[string]interface{}{"new_blocks": []interface{}{block}}, + } + + outcomes := correlateLocalDocResources(data, []localDocResource{{Occurrence: 1, Kind: localDocResourceFile, Marker: marker}}) + if len(outcomes) != 1 { + t.Fatalf("outcomes len = %d, want 1", len(outcomes)) + } + if outcomes[0].Status != "pending" || outcomes[0].BlockID != "blk_file" || !outcomes[0].SafeToCleanup { + t.Fatalf("outcome = %#v", outcomes[0]) + } +} + +func TestCorrelateLocalDocResourcesTypeMismatchIsNotSafeToCleanup(t *testing.T) { + marker := "@lcli_img_0123456789abcdef0123456789abcdef" + outcomes := correlateLocalDocResources(map[string]interface{}{ + "document": map[string]interface{}{"new_blocks": []interface{}{ + map[string]interface{}{"block_id": "blk_mismatch", "block_type": "file", "block_token": marker}, + }}, + }, []localDocResource{{Occurrence: 1, Kind: localDocResourceImage, Marker: marker}}) + + if len(outcomes) != 1 || outcomes[0].Status != "correlation_failed" || outcomes[0].SafeToCleanup { + t.Fatalf("mismatched block outcome = %#v", outcomes) + } + if len(outcomes[0].CleanupBlockIDs) != 0 || outcomes[0].CleanupStatus != "skipped_ambiguous" { + t.Fatalf("mismatched block was scheduled for cleanup: %#v", outcomes[0]) + } +} + +func TestCorrelateLocalDocResourcesMissingMarkerIsSkippedAmbiguous(t *testing.T) { + marker := "@lcli_img_0123456789abcdef0123456789abcdef" + outcomes := correlateLocalDocResources(map[string]interface{}{ + "document": map[string]interface{}{"new_blocks": []interface{}{}}, + }, []localDocResource{{Occurrence: 1, Kind: localDocResourceImage, Marker: marker}}) + + if len(outcomes) != 1 || outcomes[0].Status != "correlation_failed" || outcomes[0].SafeToCleanup { + t.Fatalf("missing marker outcome = %#v", outcomes) + } + if outcomes[0].CleanupStatus != "skipped_ambiguous" { + t.Fatalf("missing marker cleanup status = %q, want skipped_ambiguous", outcomes[0].CleanupStatus) + } + data := map[string]interface{}{} + appendLocalDocResourceFailures(data, outcomes) + failures, ok := data["local_resource_failures"].([]interface{}) + if !ok || len(failures) != 1 { + t.Fatalf("failure payload = %#v, want one failure", data["local_resource_failures"]) + } + failure, ok := failures[0].(map[string]interface{}) + if !ok || failure["cleanup_status"] != "skipped_ambiguous" { + t.Fatalf("failure payload = %#v, want cleanup_status=skipped_ambiguous", failures[0]) + } +} + +func TestCorrelateLocalDocResourcesUnknownMarkerDisablesCleanup(t *testing.T) { + expectedMarker := "@lcli_img_0123456789abcdef0123456789abcdef" + unknownMarker := "@lcli_file_fedcba9876543210fedcba9876543210" + outcomes := correlateLocalDocResources(map[string]interface{}{ + "document": map[string]interface{}{"new_blocks": []interface{}{ + map[string]interface{}{"block_id": "blk_expected", "block_type": "image", "block_token": expectedMarker}, + map[string]interface{}{"block_id": "blk_unknown", "block_type": "file", "block_token": unknownMarker}, + }}, + }, []localDocResource{{Occurrence: 1, Kind: localDocResourceImage, Marker: expectedMarker}}) + + if len(outcomes) != 1 || outcomes[0].Status != "correlation_failed" || outcomes[0].SafeToCleanup { + t.Fatalf("unknown marker outcome = %#v", outcomes) + } + if outcomes[0].CleanupStatus != "skipped_ambiguous" { + t.Fatalf("unknown marker cleanup status = %q, want skipped_ambiguous", outcomes[0].CleanupStatus) + } + for _, blockID := range outcomes[0].CleanupBlockIDs { + if blockID == "blk_unknown" { + t.Fatalf("unknown marker block was scheduled for cleanup: %#v", outcomes[0]) + } + } +} + +func TestBuildLocalDocResourceBatchUpdatePreservesImagePresentation(t *testing.T) { + image := &localDocResourceOutcome{ + Resource: localDocResource{ + Kind: localDocResourceImage, + ImageWidth: 640, + ImageHeight: 480, + ImageAlign: "right", + ImageScale: 0.5, + HasScale: true, + }, + BlockID: "blk_image", + FileToken: "file_image", + } + file := &localDocResourceOutcome{ + Resource: localDocResource{Kind: localDocResourceFile}, + BlockID: "blk_file", + FileToken: "file_attachment", + } + + body := buildLocalDocResourceBatchUpdate([]*localDocResourceOutcome{image, file}) + requests, _ := body["requests"].([]interface{}) + if len(requests) != 2 { + t.Fatalf("requests len = %d, want 2: %#v", len(requests), body) + } + imageReq, _ := requests[0].(map[string]interface{}) + replaceImage, _ := imageReq["replace_image"].(map[string]interface{}) + for key, want := range map[string]interface{}{ + "token": "file_image", + "width": 640, + "height": 480, + "align": alignMap["right"], + "scale": 0.5, + } { + if got := replaceImage[key]; got != want { + t.Fatalf("replace_image[%s] = %#v, want %#v; body=%#v", key, got, want, body) + } + } + fileReq, _ := requests[1].(map[string]interface{}) + replaceFile, _ := fileReq["replace_file"].(map[string]interface{}) + if got := replaceFile["token"]; got != "file_attachment" { + t.Fatalf("replace_file token = %#v, want file_attachment", got) + } +} + +func TestPrepareLocalDocResourcesSourceUsesExplicitUploadName(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, map[string]string{"report.pdf": "pdf-data"}) + + got, resources, err := prepareLocalDocResources(runtime, "xml", ``) + if err != nil { + t.Fatalf("prepareLocalDocResources() error: %v", err) + } + if len(resources) != 1 || resources[0].FileName != "自定义报告.pdf" { + t.Fatalf("resources = %#v, want trimmed explicit file name", resources) + } + if !strings.Contains(got, `name="自定义报告.pdf"`) { + t.Fatalf("rewritten source did not preserve trimmed name: %s", got) + } +} + +func TestPrepareLocalDocResourcesRejectsInvalidSourceName(t *testing.T) { + for _, name := range []string{" ", "../secret.pdf", `folder\secret.pdf`} { + t.Run(strings.ReplaceAll(name, "/", "_"), func(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, map[string]string{"report.pdf": "pdf-data"}) + _, _, err := prepareLocalDocResources(runtime, "xml", ``) + if err == nil { + t.Fatalf("source name %q was accepted", name) + } + }) + } +} + +func TestUploadLocalDocResourceUsesExplicitSourceName(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + if err := os.WriteFile("report.pdf", []byte("pdf-data"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-source-name")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-source-name"), factory, core.AsUser) + _, resources, err := prepareLocalDocResources(runtime, "xml", ``) + if err != nil { + t.Fatalf("prepareLocalDocResources: %v", err) + } + upload := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"file_token": "file_custom_name"}, + }, + } + reg.Register(upload) + outcome := &localDocResourceOutcome{Resource: resources[0], BlockID: "blk_file", Status: "pending"} + uploadLocalDocResources(runtime, "doxcn_source_name", []*localDocResourceOutcome{outcome}) + if outcome.Status != "uploaded" || outcome.FileToken != "file_custom_name" { + t.Fatalf("outcome = %#v", outcome) + } + body := string(upload.CapturedBody) + if !strings.Contains(body, "自定义报告.pdf") || strings.Contains(body, "\r\n\r\nreport.pdf\r\n") { + t.Fatalf("upload body did not use explicit source name: %s", body) + } +} + +func TestUploadRemoteDocImagesUsesBoundedConcurrency(t *testing.T) { + config := docsTestConfigWithAppID("remote-image-concurrent-upload") + factory, _, _, reg := cmdutil.TestFactory(t, config) + var credentialReady atomic.Bool + factory.Credential = credential.NewCredentialProvider( + nil, + &remoteImageTestAccountResolver{config: config}, + &remoteImageTestTokenResolver{resolved: &credentialReady}, + nil, + ) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, config, factory, core.AsUser) + payload := []byte(localDocResourcePNG(t, 20, 10)) + originalDownload := downloadRemoteDocImage + t.Cleanup(func() { downloadRemoteDocImage = originalDownload }) + var downloadMu sync.Mutex + downloads := 0 + var downloadBeforeCredential atomic.Bool + downloadRemoteDocImage = func(_ *common.RuntimeContext, _ string, _ int) (remoteDocImageDownload, error) { + if !credentialReady.Load() { + downloadBeforeCredential.Store(true) + } + downloadMu.Lock() + downloads++ + downloadMu.Unlock() + return remoteDocImageDownload{Content: payload, FileName: "remote.png", Width: 20, Height: 10}, nil + } + + releaseUploads := make(chan struct{}) + reachedConcurrentUpload := make(chan struct{}) + var concurrentOnce sync.Once + var uploadMu sync.Mutex + activeUploads := 0 + maxActiveUploads := 0 + upload := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Reusable: true, + OnMatch: func(*http.Request) { + uploadMu.Lock() + activeUploads++ + if activeUploads > maxActiveUploads { + maxActiveUploads = activeUploads + } + if activeUploads >= 2 { + concurrentOnce.Do(func() { close(reachedConcurrentUpload) }) + } + uploadMu.Unlock() + <-releaseUploads + uploadMu.Lock() + activeUploads-- + uploadMu.Unlock() + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"file_token": "file_remote_image"}, + }, + } + reg.Register(upload) + outcomes := make([]*localDocResourceOutcome, remoteDocImageUploadConcurrency+2) + for i := range outcomes { + occurrence := i + 1 + outcomes[i] = &localDocResourceOutcome{ + Resource: localDocResource{ + Occurrence: occurrence, + Kind: localDocResourceImage, + RemoteURL: fmt.Sprintf("https://93.184.216.34/%d.png", occurrence), + Content: payload, + }, + BlockID: fmt.Sprintf("blk_remote_image_%d", occurrence), + Status: "pending", + } + } + + done := make(chan struct{}) + go func() { + uploadLocalDocResources(runtime, "doxcn_remote_image", outcomes) + close(done) + }() + select { + case <-reachedConcurrentUpload: + close(releaseUploads) + case <-time.After(2 * time.Second): + close(releaseUploads) + <-done + t.Fatalf("remote image uploads did not overlap; max active uploads = %d", maxActiveUploads) + } + <-done + downloadMu.Lock() + gotDownloads := downloads + downloadMu.Unlock() + if gotDownloads != len(outcomes) { + t.Fatalf("remote image downloads = %d, want %d", gotDownloads, len(outcomes)) + } + if downloadBeforeCredential.Load() { + t.Fatal("remote image worker started before credentials were resolved") + } + if maxActiveUploads < 2 { + t.Fatalf("max active remote image uploads = %d, want at least 2", maxActiveUploads) + } + if maxActiveUploads > remoteDocImageUploadConcurrency { + t.Fatalf("max active remote image uploads = %d, concurrency limit = %d", maxActiveUploads, remoteDocImageUploadConcurrency) + } + for _, outcome := range outcomes { + if outcome.Status != "uploaded" || outcome.FileToken != "file_remote_image" { + t.Fatalf("outcome = %#v", outcome) + } + if len(outcome.Resource.Content) != 0 { + t.Fatalf("remote image #%d retained %d buffered bytes after upload", outcome.Resource.Occurrence, len(outcome.Resource.Content)) + } + } +} + +type remoteImageTestAccountResolver struct { + config *core.CliConfig +} + +func (r *remoteImageTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) { + return credential.AccountFromCliConfig(r.config), nil +} + +type remoteImageTestTokenResolver struct { + resolved *atomic.Bool +} + +func (r *remoteImageTestTokenResolver) ResolveToken(context.Context, credential.TokenSpec) (*credential.TokenResult, error) { + r.resolved.Store(true) + return &credential.TokenResult{Token: "test-token"}, nil +} + +func TestUploadLocalDocResourcesRetriesConflictAndSerializes(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + for _, name := range []string{"first.png", "second.png"} { + if err := os.WriteFile(name, []byte(name), 0o600); err != nil { + t.Fatalf("WriteFile(%s): %v", name, err) + } + } + factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-upload-serial")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-upload-serial"), factory, core.AsUser) + reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/drive/v1/medias/upload_all", Body: map[string]interface{}{"code": localDocResourceUploadConflictCode, "msg": "material transaction conflict"}}) + reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/drive/v1/medias/upload_all", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "file_first"}}}) + reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/drive/v1/medias/upload_all", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "file_second"}}}) + outcomes := []*localDocResourceOutcome{ + {Resource: localDocResource{Occurrence: 1, Kind: localDocResourceImage, Path: "first.png", FileName: "first.png", Size: int64(len("first.png"))}, BlockID: "blk_first", Status: "pending"}, + {Resource: localDocResource{Occurrence: 2, Kind: localDocResourceImage, Path: "second.png", FileName: "second.png", Size: int64(len("second.png"))}, BlockID: "blk_second", Status: "pending"}, + } + var waits []time.Duration + originalWait := waitLocalDocResourceRequest + waitLocalDocResourceRequest = func(_ context.Context, delay time.Duration) error { + waits = append(waits, delay) + return nil + } + t.Cleanup(func() { waitLocalDocResourceRequest = originalWait }) + uploadLocalDocResources(runtime, "doxcn_upload_serial", outcomes) + if outcomes[0].FileToken != "file_first" || outcomes[1].FileToken != "file_second" { + t.Fatalf("outcomes = %#v", outcomes) + } + if len(waits) != 2 { + t.Fatalf("upload pacing waits = %#v", waits) + } + retryWaits := waits[:1] + assertLocalDocResourceRetryWaits(t, retryWaits, 1) + if waits[1] != localDocResourceUploadInterval { + t.Fatalf("serial upload pacing wait = %v, want %v", waits[1], localDocResourceUploadInterval) + } +} + +func assertLocalDocResourceRetryWaits(t *testing.T, waits []time.Duration, want int) { + t.Helper() + if len(waits) != want { + t.Fatalf("retry waits = %#v, want %d", waits, want) + } + for attempt, got := range waits { + base := localDocResourceUploadInterval * time.Duration(1< max { + t.Fatalf("retry wait[%d] = %v, want in [%v, %v]", attempt, got, base, max) + } + } +} + +func TestPrepareLocalDocResourcesRejectsDuplicateAttributes(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, map[string]string{ + "diagram.png": localDocResourcePNG(t, 2, 1), + "secret.png": localDocResourcePNG(t, 3, 1), + }) + _, resources, err := prepareLocalDocResources(runtime, "xml", ``) + if err == nil { + t.Fatal("duplicate path attributes were accepted") + } + if len(resources) != 0 { + t.Fatalf("duplicate attributes planned resources: %#v", resources) + } + if strings.Contains(err.Error(), "secret.png") { + t.Fatalf("duplicate-attribute error leaked the second local path: %v", err) + } +} + +func TestCorrelateLocalDocResourcesRejectsDuplicateBlockID(t *testing.T) { + markers := []string{ + "@lcli_img_0123456789abcdef0123456789abcdef", + "@lcli_img_fedcba9876543210fedcba9876543210", + } + blocks := []interface{}{ + map[string]interface{}{"block_id": "blk_shared", "block_type": "image", "block_token": markers[0]}, + map[string]interface{}{"block_id": "blk_shared", "block_type": "image", "block_token": markers[1]}, + } + outcomes := correlateLocalDocResources(map[string]interface{}{ + "document": map[string]interface{}{"new_blocks": blocks}, + }, []localDocResource{ + {Occurrence: 1, Kind: localDocResourceImage, Marker: markers[0]}, + {Occurrence: 2, Kind: localDocResourceImage, Marker: markers[1]}, + }) + cleanupCount := 0 + for _, outcome := range outcomes { + if outcome.Status != "correlation_failed" { + t.Fatalf("duplicate block outcome = %#v", outcome) + } + cleanupCount += len(outcome.CleanupBlockIDs) + } + if cleanupCount != 1 { + t.Fatalf("cleanup block IDs = %d, want one deduplicated delete", cleanupCount) + } +} + +func TestPrepareLocalDocResourcesMarkdownIgnoresNestedFencesAndRawHTML(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)}) + content := strings.Join([]string{ + "> ```xml", + "> ", + "> ```", + "- ```markdown", + " ![list code](@diagram.png)", + " ```", + "
",
+		"```",
+		"",
+		"```",
+		"
", + "", + "", + "", + `\`, + }, "\n") + + got, resources, err := prepareLocalDocResources(runtime, "markdown", content) + if err != nil { + t.Fatalf("prepareLocalDocResources: %v", err) + } + if got != content || len(resources) != 0 { + t.Fatalf("inert Markdown changed: resources=%#v\n got=%q\nwant=%q", resources, got, content) + } +} + +func TestPrepareLocalDocResourcesXMLBackticksAreNotInert(t *testing.T) { + runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)}) + got, resources, err := prepareLocalDocResources(runtime, "xml", "``") + if err != nil { + t.Fatalf("prepareLocalDocResources: %v", err) + } + if len(resources) != 1 || !strings.Contains(got, resources[0].Marker) { + t.Fatalf("XML backticks incorrectly hid resource: got=%q resources=%#v", got, resources) + } +} + +func TestMarkdownUnescapePreservesNonPunctuationBackslashes(t *testing.T) { + if got, want := unescapeMarkdownText(`C:\temp\photo \*draft\* \\`), `C:\temp\photo *draft* \`; got != want { + t.Fatalf("unescapeMarkdownText() = %q, want %q", got, want) + } + image, ok := parseMarkdownImageAt(`![x](@images\photo.png)`, 0) + if !ok || image.Destination != `@images\photo.png` { + t.Fatalf("parsed destination = %q, ok=%v", image.Destination, ok) + } +} + +func TestBindLocalDocResourcesBatchesTwentyAndPaces(t *testing.T) { + factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-bind-batch")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-bind-batch"), factory, core.AsUser) + first := &httpmock.Stub{Method: "PATCH", URL: "/open-apis/docx/v1/documents/doxcn_batch/blocks/batch_update", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"document_revision_id": 1}}} + second := &httpmock.Stub{Method: "PATCH", URL: "/open-apis/docx/v1/documents/doxcn_batch/blocks/batch_update", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"document_revision_id": 2}}} + reg.Register(first) + reg.Register(second) + outcomes := make([]*localDocResourceOutcome, 21) + for i := range outcomes { + outcomes[i] = &localDocResourceOutcome{ + Resource: localDocResource{Occurrence: i + 1, Kind: localDocResourceImage}, + BlockID: fmt.Sprintf("blk_%d", i), + FileToken: fmt.Sprintf("file_%d", i), + Status: "uploaded", + } + } + var waits []time.Duration + originalWait := waitLocalDocResourceRequest + waitLocalDocResourceRequest = func(_ context.Context, delay time.Duration) error { + waits = append(waits, delay) + return nil + } + t.Cleanup(func() { waitLocalDocResourceRequest = originalWait }) + + revision, revisionKnown := bindLocalDocResources(runtime, "doxcn_batch", outcomes) + if !revisionKnown || revision != int64(2) { + t.Fatalf("revision = %#v, want 2", revision) + } + if got := requestCountFromLocalDocBatchBody(t, first.CapturedBody); got != 20 { + t.Fatalf("first batch size = %d, want 20", got) + } + if got := requestCountFromLocalDocBatchBody(t, second.CapturedBody); got != 1 { + t.Fatalf("second batch size = %d, want 1", got) + } + if len(waits) != 1 || waits[0] != localDocResourceBindInterval { + t.Fatalf("bind pacing waits = %#v", waits) + } +} + +func TestNormalizeLocalDocResourceRevision(t *testing.T) { + for _, tt := range []struct { + name string + value interface{} + want interface{} + }{ + {name: "integer", value: 7, want: int64(7)}, + {name: "float integer", value: float64(8), want: int64(8)}, + {name: "json number", value: json.Number("9"), want: int64(9)}, + {name: "numeric string", value: "10", want: int64(10)}, + {name: "negative sentinel", value: -1}, + {name: "fraction", value: 1.5}, + {name: "invalid string", value: "latest"}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeLocalDocResourceRevision(tt.value); got != tt.want { + t.Fatalf("normalizeLocalDocResourceRevision(%#v) = %#v, want %#v", tt.value, got, tt.want) + } + }) + } +} + +func TestBindLocalDocResourceRetryUsesStableTokenAndBackoff(t *testing.T) { + factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-bind-retry")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-bind-retry"), factory, core.AsUser) + var clientTokens []string + for _, stub := range []*httpmock.Stub{ + {Method: "PATCH", URL: "/open-apis/docx/v1/documents/doxcn_retry/blocks/batch_update", Body: map[string]interface{}{"code": localDocResourceUploadRateLimitCode, "msg": "rate limited"}, OnMatch: func(req *http.Request) { clientTokens = append(clientTokens, req.URL.Query().Get("client_token")) }}, + {Method: "GET", URL: "/open-apis/docx/v1/documents/doxcn_retry/blocks/blk_retry", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{"image": map[string]interface{}{"token": ""}}}}}, + {Method: "PATCH", URL: "/open-apis/docx/v1/documents/doxcn_retry/blocks/batch_update", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, OnMatch: func(req *http.Request) { clientTokens = append(clientTokens, req.URL.Query().Get("client_token")) }}, + } { + reg.Register(stub) + } + var waits []time.Duration + originalWait := waitLocalDocResourceRequest + waitLocalDocResourceRequest = func(_ context.Context, delay time.Duration) error { + waits = append(waits, delay) + return nil + } + t.Cleanup(func() { waitLocalDocResourceRequest = originalWait }) + outcome := &localDocResourceOutcome{Resource: localDocResource{Kind: localDocResourceImage}, BlockID: "blk_retry", FileToken: "file_retry", Status: "uploaded"} + _, _ = bindLocalDocResourceChunk(runtime, "doxcn_retry", []*localDocResourceOutcome{outcome}) + if outcome.Status != "bound" || len(clientTokens) != 2 || clientTokens[0] == "" || clientTokens[0] != clientTokens[1] { + t.Fatalf("outcome=%#v client_tokens=%#v", outcome, clientTokens) + } + if len(waits) != 1 || waits[0] != localDocResourceBindInterval { + t.Fatalf("retry waits = %#v", waits) + } +} + +func TestBindLocalDocResourceAmbiguousSuccessDropsStaleRevision(t *testing.T) { + factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-bind-ambiguous-success")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-bind-ambiguous-success"), factory, core.AsUser) + reg.Register(&httpmock.Stub{Method: "PATCH", URL: "/open-apis/docx/v1/documents/doxcn_ambiguous/blocks/batch_update", Body: map[string]interface{}{"code": localDocResourceUploadRateLimitCode, "msg": "rate limited"}}) + reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/docx/v1/documents/doxcn_ambiguous/blocks/blk_ambiguous", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{"image": map[string]interface{}{"token": "file_ambiguous"}}}}}) + outcome := &localDocResourceOutcome{Resource: localDocResource{Kind: localDocResourceImage}, BlockID: "blk_ambiguous", FileToken: "file_ambiguous", Status: "uploaded"} + revision, revisionKnown := bindLocalDocResourceChunk(runtime, "doxcn_ambiguous", []*localDocResourceOutcome{outcome}) + if revision != nil || revisionKnown || outcome.Status != "bound" { + t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome) + } +} + +func TestCleanupLocalDocResourcePlaceholdersRevalidatesToken(t *testing.T) { + tests := []struct { + name string + getStatus int + block map[string]interface{} + wantDelete bool + wantStatus string + wantCleanup string + wantKnown bool + wantRevision interface{} + }{ + {name: "empty", block: map[string]interface{}{"block_type": 27, "image": map[string]interface{}{"token": ""}}, wantDelete: true, wantStatus: "bind_failed", wantCleanup: "succeeded", wantKnown: true, wantRevision: int64(8)}, + {name: "ours", block: map[string]interface{}{"block_type": 27, "image": map[string]interface{}{"token": "file_ours"}}, wantStatus: "bound", wantCleanup: "not_needed"}, + {name: "other", block: map[string]interface{}{"block_type": 27, "image": map[string]interface{}{"token": "file_other"}}, wantStatus: "bind_conflict", wantCleanup: "skipped_conflict"}, + {name: "other_kind_token", block: map[string]interface{}{"block_type": 27, "image": map[string]interface{}{"token": ""}, "file": map[string]interface{}{"token": "file_hidden"}}, wantStatus: "bind_conflict", wantCleanup: "skipped_conflict"}, + {name: "type_mismatch", block: map[string]interface{}{"block_type": 23, "file": map[string]interface{}{"token": ""}}, wantStatus: "bind_ambiguous", wantCleanup: "skipped_ambiguous"}, + {name: "type_unknown", block: map[string]interface{}{"block_type": 999, "image": map[string]interface{}{"token": ""}}, wantStatus: "bind_ambiguous", wantCleanup: "skipped_ambiguous"}, + {name: "get_fail", getStatus: 503, wantStatus: "bind_ambiguous", wantCleanup: "skipped_ambiguous"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-"+tt.name)) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-"+tt.name), factory, core.AsUser) + getStub := &httpmock.Stub{Method: "GET", URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_cleanup"} + if tt.getStatus != 0 { + getStub.Status = tt.getStatus + getStub.RawBody = []byte("temporary") + } else { + getStub.Body = map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": tt.block}} + } + reg.Register(getStub) + deleteCalls := 0 + var deleteStub *httpmock.Stub + if tt.wantDelete { + deleteStub = &httpmock.Stub{Method: "PUT", URL: "/open-apis/docs_ai/v1/documents/doxcn_cleanup", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"document": map[string]interface{}{"revision_id": 8}}}, OnMatch: func(*http.Request) { deleteCalls++ }} + reg.Register(deleteStub) + } + outcome := &localDocResourceOutcome{ + Resource: localDocResource{Occurrence: 1, Kind: localDocResourceImage}, + BlockID: "blk_cleanup", + CleanupBlockIDs: []string{"blk_cleanup"}, + FileToken: "file_ours", + Status: "bind_failed", + CleanupStatus: "pending", + SafeToCleanup: true, + } + revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, float64(7)) + if got := deleteCalls > 0; got != tt.wantDelete { + t.Fatalf("delete called=%v, want %v", got, tt.wantDelete) + } + if revisionKnown != tt.wantKnown || revision != tt.wantRevision { + t.Fatalf("revision=%#v known=%v, want revision=%#v known=%v", revision, revisionKnown, tt.wantRevision, tt.wantKnown) + } + if outcome.Status != tt.wantStatus || outcome.CleanupStatus != tt.wantCleanup || outcome.SafeToCleanup { + t.Fatalf("outcome=%#v, want status=%s cleanup=%s safe=false", outcome, tt.wantStatus, tt.wantCleanup) + } + if tt.wantDelete { + var body map[string]interface{} + if err := json.Unmarshal(deleteStub.CapturedBody, &body); err != nil { + t.Fatalf("decode cleanup body: %v", err) + } + if body["revision_id"] != float64(7) { + t.Fatalf("cleanup revision_id=%#v, want 7", body["revision_id"]) + } + } + }) + } +} + +func TestCleanupLocalDocResourcePlaceholdersRequiresKnownRevision(t *testing.T) { + factory, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-no-revision")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-no-revision"), factory, core.AsUser) + outcome := &localDocResourceOutcome{Resource: localDocResource{Kind: localDocResourceImage}, CleanupBlockIDs: []string{"blk_cleanup"}, Status: "upload_failed", CleanupStatus: "pending", SafeToCleanup: true} + revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, nil) + if revision != nil || revisionKnown || outcome.CleanupStatus != "skipped_ambiguous" || outcome.SafeToCleanup { + t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome) + } +} + +func TestCleanupLocalDocResourcePlaceholdersPreservesServiceFailure(t *testing.T) { + factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-service-failure")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-service-failure"), factory, core.AsUser) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_cleanup", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{ + "block_type": 27, + "image": map[string]interface{}{"token": ""}, + }}}, + }) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/docs_ai/v1/documents/doxcn_cleanup", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "result": "failed", + "warnings": []interface{}{"target block cannot be deleted"}, + }}, + }) + outcome := &localDocResourceOutcome{ + Resource: localDocResource{Occurrence: 1, Kind: localDocResourceImage}, + BlockID: "blk_cleanup", + CleanupBlockIDs: []string{"blk_cleanup"}, + Status: "upload_failed", + CleanupStatus: "pending", + SafeToCleanup: true, + } + + revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, float64(7)) + + if revision != nil || revisionKnown || outcome.CleanupStatus != "failed" || outcome.SafeToCleanup { + t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome) + } + if len(outcome.ServerWarnings) != 1 || outcome.ServerWarnings[0] != "target block cannot be deleted" { + t.Fatalf("server warnings = %#v", outcome.ServerWarnings) + } + problem, ok := errs.ProblemOf(outcome.Err) + if !ok || problem.Category != errs.CategoryAPI { + t.Fatalf("cleanup error = %T %v, problem=%#v", outcome.Err, outcome.Err, problem) + } +} + +func TestCleanupLocalDocResourceFileDeletesFigureParent(t *testing.T) { + factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-file-parent")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-file-parent"), factory, core.AsUser) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_file", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{ + "block_id": "blk_file", + "block_type": 23, + "parent_id": "blk_figure", + "file": map[string]interface{}{"token": ""}, + }}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_figure", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{ + "block_id": "blk_figure", + "block_type": 33, + "children": []interface{}{"blk_file"}, + "view": map[string]interface{}{"view_type": 2}, + }}}, + }) + deleteStub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/docs_ai/v1/documents/doxcn_cleanup", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"document": map[string]interface{}{"revision_id": 8}}}, + } + reg.Register(deleteStub) + outcome := &localDocResourceOutcome{ + Resource: localDocResource{Occurrence: 1, Kind: localDocResourceFile}, + BlockID: "blk_file", + CleanupBlockIDs: []string{"blk_file"}, + Status: "upload_failed", + CleanupStatus: "pending", + SafeToCleanup: true, + } + + revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, float64(7)) + + if !revisionKnown || revision != int64(8) || outcome.CleanupStatus != "succeeded" { + t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome) + } + var body map[string]interface{} + if err := json.Unmarshal(deleteStub.CapturedBody, &body); err != nil { + t.Fatalf("decode cleanup body: %v", err) + } + if body["block_id"] != "blk_figure" { + t.Fatalf("cleanup block_id=%#v, want figure parent", body["block_id"]) + } +} + +func TestCleanupLocalDocResourceFileDoesNotDeleteInlineParent(t *testing.T) { + factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-inline-file")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-inline-file"), factory, core.AsUser) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_file", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{ + "block_id": "blk_file", + "block_type": 23, + "parent_id": "blk_paragraph", + "file": map[string]interface{}{"token": ""}, + }}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_paragraph", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{ + "block_id": "blk_paragraph", + "block_type": 2, + "children": []interface{}{"blk_file"}, + "text": map[string]interface{}{"elements": []interface{}{map[string]interface{}{"text_run": map[string]interface{}{"content": "keep me"}}}}, + }}}, + }) + outcome := &localDocResourceOutcome{ + Resource: localDocResource{Occurrence: 1, Kind: localDocResourceFile}, + BlockID: "blk_file", + CleanupBlockIDs: []string{"blk_file"}, + Status: "upload_failed", + CleanupStatus: "pending", + SafeToCleanup: true, + } + + revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, float64(7)) + + if revision != nil || revisionKnown || outcome.Status != "bind_ambiguous" || outcome.CleanupStatus != "skipped_ambiguous" || outcome.SafeToCleanup { + t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome) + } + if outcome.Err == nil || !strings.Contains(outcome.Err.Error(), "sole-source figure") { + t.Fatalf("outcome error = %v, want parent verification failure", outcome.Err) + } +} + +func TestCleanupLocalDocResourceFileWithoutFigureParentIsPreserved(t *testing.T) { + factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-file-no-parent")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-file-no-parent"), factory, core.AsUser) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_file", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{ + "block_id": "blk_file", + "block_type": 23, + "file": map[string]interface{}{"token": ""}, + }}}, + }) + outcome := &localDocResourceOutcome{ + Resource: localDocResource{Occurrence: 1, Kind: localDocResourceFile}, + BlockID: "blk_file", + CleanupBlockIDs: []string{"blk_file"}, + Status: "upload_failed", + CleanupStatus: "pending", + SafeToCleanup: true, + } + + revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, float64(7)) + + if revision != nil || revisionKnown || outcome.Status != "bind_ambiguous" || outcome.CleanupStatus != "skipped_ambiguous" || outcome.SafeToCleanup { + t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome) + } +} + +func TestDocAPINullDataIsTypedInvalidResponse(t *testing.T) { + factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-null-data")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-null-data"), factory, core.AsUser) + reg.Register(&httpmock.Stub{Method: "PUT", URL: "/open-apis/docs_ai/v1/documents/doxcn_null", Body: map[string]interface{}{"code": 0, "data": nil}}) + _, err := doDocAPI(runtime, "PUT", "/open-apis/docs_ai/v1/documents/doxcn_null", map[string]interface{}{}) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("error = %T %v, want typed invalid_response", err, err) + } +} + +func TestFinalizeLocalDocResourcesTOCTOUErrorDoesNotLeakCWD(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + if err := os.WriteFile("vanished.png", []byte(localDocResourcePNG(t, 2, 1)), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + factory, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-toctou")) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-toctou"), factory, core.AsUser) + _, resources, err := prepareLocalDocResources(runtime, "xml", ``) + if err != nil { + t.Fatalf("prepareLocalDocResources: %v", err) + } + if err := os.Remove("vanished.png"); err != nil { + t.Fatalf("Remove: %v", err) + } + block := map[string]interface{}{"block_id": "blk_vanished", "block_type": "image", "block_token": resources[0].Marker} + data := map[string]interface{}{"document": map[string]interface{}{"revision_id": 1, "new_blocks": []interface{}{block}}} + reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/docx/v1/documents/doxcn_toctou/blocks/blk_vanished", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{"block_type": 27, "image": map[string]interface{}{"token": ""}}}}}) + reg.Register(&httpmock.Stub{Method: "PUT", URL: "/open-apis/docs_ai/v1/documents/doxcn_toctou", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"document": map[string]interface{}{"revision_id": 2}}}}) + if err := finalizeLocalDocResources(runtime, "doxcn_toctou", data, resources); err == nil { + t.Fatal("finalizeLocalDocResources error = nil, want partial failure") + } + public, _ := json.Marshal(data) + public = append(public, stdout.Bytes()...) + for _, secret := range []string{dir, "vanished.png", resources[0].Marker} { + if strings.Contains(string(public), secret) { + t.Fatalf("partial response leaked %q: %s", secret, public) + } + } + var envelope struct { + Data struct { + Failures []struct { + Occurrence int `json:"occurrence"` + Kind string `json:"kind"` + Status string `json:"status"` + Cleanup string `json:"cleanup_status"` + Error struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + } `json:"error"` + } `json:"local_resource_failures"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode partial failure: %v", err) + } + if len(envelope.Data.Failures) != 1 || envelope.Data.Failures[0].Occurrence != 1 || + envelope.Data.Failures[0].Kind != "image" || envelope.Data.Failures[0].Status != "upload_failed" || + envelope.Data.Failures[0].Error.Type == "" || envelope.Data.Failures[0].Error.Subtype == "" { + t.Fatalf("failure details = %#v", envelope.Data.Failures) + } +} + +func TestLocalDocResourceDryRunIncludesRouteExtraPartSizeAndTwentyBatch(t *testing.T) { + resources := make([]localDocResource, 21) + for i := range resources { + resources[i] = localDocResource{Occurrence: i + 1, Kind: localDocResourceImage, Size: common.MaxDriveMediaUploadSinglePartSize + 1} + } + dry := appendLocalDocResourcesDryRun(common.NewDryRunAPI(), "doc/with space", resources) + decoded := decodeDocDryRun(t, dry) + patches := 0 + for _, api := range decoded.API { + switch { + case api.URL == "/open-apis/drive/v1/medias/upload_prepare": + if !strings.Contains(fmt.Sprint(api.Body["extra"]), "drive_route_token") || api.Body["size"] == nil { + t.Fatalf("upload_prepare body = %#v", api.Body) + } + case api.URL == "/open-apis/drive/v1/medias/upload_part": + if api.Body["size"] == nil { + t.Fatalf("upload_part body missing size: %#v", api.Body) + } + case strings.Contains(api.URL, "/blocks/batch_update"): + patches++ + if !strings.Contains(api.URL, "doc%2Fwith%20space") { + t.Fatalf("batch URL is not encoded: %s", api.URL) + } + } + } + if patches != 2 { + t.Fatalf("batch PATCH count = %d, want 2", patches) + } +} + +func TestDocsUpdateLocalResourceWikiDryRunResolvesDocxFirst(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + if err := os.WriteFile("diagram.png", []byte(localDocResourcePNG(t, 2, 1)), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{ + "doc": "https://example.larksuite.com/wiki/wikcn_local", + "command": "append", + "content": ``, + }) + dry := decodeDocDryRun(t, dryRunUpdateV2(context.Background(), runtime)) + if len(dry.API) < 2 || dry.API[0].URL != "/open-apis/wiki/v2/spaces/get_node" { + t.Fatalf("dry-run must resolve wiki first: %#v", dry.API) + } + if got := dry.API[1].URL; got != "/open-apis/docs_ai/v1/documents/%3Cresolved_docx_token%3E" { + t.Fatalf("docs_ai URL = %q", got) + } + for _, api := range dry.API[2:] { + if strings.Contains(api.URL, "wikcn_local") { + t.Fatalf("post-resolve API still uses wiki node token: %s", api.URL) + } + } +} + +func TestDocsUpdateLocalResourceRejectsLegacyDocBeforeWrite(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + if err := os.WriteFile("diagram.png", []byte(localDocResourcePNG(t, 2, 1)), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{ + "doc": "https://example.larksuite.com/doc/docc_legacy", + "command": "append", + "content": ``, + }) + err := validateUpdateV2(context.Background(), runtime) + problem, ok := errs.ProblemOf(err) + var validationErr *errs.ValidationError + if !ok || problem.Subtype != errs.SubtypeInvalidArgument || + !errors.As(err, &validationErr) || validationErr.Param != "--doc" { + t.Fatalf("error=%T %v problem=%#v validation=%#v", err, err, problem, validationErr) + } +} + +func TestDocsUpdateRemoteImageDryRunDownloadsAfterDocumentWrite(t *testing.T) { + runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{ + "doc": "doxcn_remote_image", + "command": "append", + "content": ``, + }) + dry := decodeDocDryRun(t, dryRunUpdateV2(context.Background(), runtime)) + if len(dry.API) != 6 { + t.Fatalf("dry-run API calls = %d, want 6: %#v", len(dry.API), dry.API) + } + if got := dry.API[0].URL; got != "/open-apis/docs_ai/v1/documents/doxcn_remote_image" { + t.Fatalf("document update URL = %q", got) + } + if got := dry.API[1].URL; got != "https://93.184.216.34/photo.png" { + t.Fatalf("download URL = %q", got) + } + if got := dry.API[2].URL; got != "/open-apis/drive/v1/medias/upload_all" { + t.Fatalf("upload URL = %q", got) + } + preparedContent := fmt.Sprint(dry.API[0].Body["content"]) + if strings.Contains(preparedContent, `href=`) || !strings.Contains(preparedContent, "@lcli_img_") { + t.Fatalf("prepared content = %q", preparedContent) + } +} + +func TestDocsUpdateV2RejectsBlockedRemoteImageBeforeDocumentWrite(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-update-blocked-remote-image")) + updateStub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/docs_ai/v1/documents/doxcn_blocked_remote_image", + Optional: true, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + } + reg.Register(updateStub) + + err := mountAndRunDocs(t, DocsUpdate, []string{ + "+update", + "--doc", "doxcn_blocked_remote_image", + "--command", "append", + "--content", `

text that must not be written

`, + "--as", "bot", + }, f, stdout) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, "href") + if len(updateStub.CapturedBodies) != 0 { + t.Fatalf("document update was called before remote image validation: %s", updateStub.CapturedBody) + } +} + +func TestLocalDocResourceUpdateCommands(t *testing.T) { + resources := []localDocResource{{Kind: localDocResourceImage}} + for _, command := range []string{"str_replace"} { + if err := validateLocalDocResourceUpdateCommand(command, resources); err == nil { + t.Fatalf("command %s accepted local resources", command) + } + } + for _, command := range []string{"append", "block_insert_after", "block_replace", "overwrite"} { + if err := validateLocalDocResourceUpdateCommand(command, resources); err != nil { + t.Fatalf("command %s rejected: %v", command, err) + } + } +} + +func TestDocsUpdateLocalResourceBlockReplaceDryRun(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + if err := os.WriteFile("replacement.png", []byte(localDocResourcePNG(t, 2, 1)), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{ + "doc": "doxcn_block_replace", + "command": "block_replace", + "block-id": "blk_target", + "content": ``, + }) + + dry := decodeDocDryRun(t, dryRunUpdateV2(context.Background(), runtime)) + if len(dry.API) != 5 { + t.Fatalf("dry-run API calls = %d, want 5: %#v", len(dry.API), dry.API) + } + update := dry.API[0] + if got := update.Body["command"]; got != "block_replace" { + t.Fatalf("update command = %#v, want block_replace", got) + } + if got := update.Body["block_id"]; got != "blk_target" { + t.Fatalf("update block_id = %#v, want blk_target", got) + } + preparedContent := fmt.Sprint(update.Body["content"]) + if strings.Contains(preparedContent, "@replacement.png") || !strings.Contains(preparedContent, "@lcli_img_") { + t.Fatalf("prepared content = %q, want private marker without local path", preparedContent) + } + if got := dry.API[1].URL; got != "/open-apis/drive/v1/medias/upload_all" { + t.Fatalf("upload URL = %q", got) + } + if got := dry.API[2].URL; got != "/open-apis/docx/v1/documents/doxcn_block_replace/blocks/batch_update" { + t.Fatalf("bind URL = %q", got) + } +} + +func requestCountFromLocalDocBatchBody(t *testing.T, raw []byte) int { + t.Helper() + var body struct { + Requests []interface{} `json:"requests"` + } + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode batch body: %v; %s", err, raw) + } + return len(body.Requests) +} + +func newLocalDocResourceTestRuntime(t *testing.T, files map[string]string) *common.RuntimeContext { + t.Helper() + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + for name, content := range files { + if err := os.WriteFile(name, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(%s): %v", name, err) + } + } + factory, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-resource-test")) + return common.TestNewRuntimeContextForAPI( + context.Background(), + &cobra.Command{Use: "docs local-resource-test"}, + docsTestConfigWithAppID("local-resource-test"), + factory, + core.AsUser, + ) +} + +func localDocResourcePNG(t *testing.T, width, height int) string { + t.Helper() + var buf bytes.Buffer + if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, width, height))); err != nil { + t.Fatalf("encode %dx%d PNG: %v", width, height, err) + } + return buf.String() +} diff --git a/shortcuts/doc/shortcuts.go b/shortcuts/doc/shortcuts.go index 85d28062f9..6b5eced0de 100644 --- a/shortcuts/doc/shortcuts.go +++ b/shortcuts/doc/shortcuts.go @@ -17,6 +17,7 @@ func Shortcuts() []common.Shortcut { DocsCreate, DocsFetch, DocsUpdate, + DocsScript, DocsHistoryList, DocsHistoryRevert, DocsHistoryRevertStatus, diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index dba530dc3d..3cf3a8ea7b 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -1,7 +1,6 @@ --- name: lark-doc -version: 2.0.0 -description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。" +description: "飞书云文档(Docx / Wiki)内容操作:读取、创建、编辑文档,插入或下载图片附件,以及操作思维笔记。用户提供文档 URL/token(包括 doubao.com 的 /docx/、/wiki/)时使用;按 URL 路径/token 而非域名路由。文档内嵌资源按读取参考中的统一规则分流。文档评论走 lark-drive;表格或 Base 内部数据操作不在本 skill。" metadata: requires: bins: ["lark-cli"] @@ -11,75 +10,40 @@ metadata: # docs -**身份:文档操作默认使用 `--as user`。首次使用前执行 `lark-cli auth login`。** +## 场景与 Shortcut 路由 -```bash -# 常用示例 -lark-cli docs +fetch --doc "文档URL或token;若 URL 存在 #share-... 锚点,优先使用锚点方式读取,不要全文拉取" -lark-cli docs +create --content '标题

内容

' -lark-cli docs +update --doc "文档URL或token" --command append --content '

内容

' -``` +**CRITICAL:先判断场景,再读取该场景的参考文件;不要在任务开始时一次性读取全部参考文件。每个文件只在首次进入对应阶段时读取一次。** -## 前置条件 — 执行操作前必读 +**身份:文档操作推荐显式指定 `--as user`。** -**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` 选择、局部读取策略、`` / `` 输出结构) -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) +**所有表示本地文件的 `@path` 均使用 `@./xxx` 形式的相对路径,并以运行 `lark-cli` 时的当前工作目录(CWD)为基准。** -**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。** +### 文档内容 -> **格式选择规则(全局):** -> - **创建 / 导入场景**(`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 更简单就自行切换。 +- **读取 / 摘要 — [`+fetch`](references/lark-doc-fetch.md)**:先读参考再获取文档。 +- **从零创作 — [`创建工作流`](references/lark-doc-create-workflow.md)**:先完整执行创建工作流,**简单任务不是跳过的理由**; +- **导入 / 空文档 — [`+create`](references/lark-doc-create.md)**:仅创建空文档或原样导入用户提供的完整内容时,跳过创建工作流。 +- **编辑 / block 直达链接 — [`+update`](references/lark-doc-update.md)**:语义改写、润色、重组、补写或排版均按 update 参考完成。 -## 快速决策 -- 用户要**复制文档 / 创建文档副本 / 另存为副本**时,切到 [`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` 处理 -- 文档内容中出现嵌入的 ``、`` 或 `` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身 +### 辅助能力 -| 标签 / 属性 | 提取字段 | 切到技能 | -|-|-|-| -| `` | `token` -> spreadsheet_token, `sheet-id` | [`lark-sheets`](../lark-sheets/SKILL.md) | -| `` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) | -| `` | 同 `` | [`lark-sheets`](../lark-sheets/SKILL.md) | -| `` | 同 `` | [`lark-base`](../lark-base/SKILL.md) | -| `` | `vc-node-id` -> note_id | [`lark-note`](../lark-note/SKILL.md):先 `note +detail --note-id ` | -| `` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch` 读取 src-token 文档,定位 block | +- **草稿初始化、解析与统计 — [`+script`](references/lark-doc-script.md)**:支持解析文档 URL / token 与本地 XML,统计字数并返回字符诊断;不支持 Markdown 输入。 +- **历史版本 — [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md)**:查询、回滚文档历史版本或检查回滚任务状态。 -## Shortcuts(推荐优先使用) +### 资源、画板与思维笔记 -Shortcut 是对常用操作的高级封装(`lark-cli docs + [flags]`)。有 Shortcut 的操作优先使用。 +- **插入本地素材 — [`+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) 写入。 +- **思维笔记 — `mindnotes`**:已有思维笔记走 [`思维笔记链路`](references/lark-doc-mindnote.md);新建思维笔记走 [`lark-doc-whiteboard`](references/lark-doc-whiteboard.md)。 -| 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. | +### 认证与 Scope + +执行 Shortcut 时,不预读 [`lark-shared`](../lark-shared/SKILL.md) 或预跑 `auth status --verify`;仅遇到未认证、token / 身份或 scope 错误时读取该 Skill,修复后重试。认证、身份或 scope 管理请求则直接使用该 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) +- **Drive 文件级操作**:找文档、导入导出、云空间文件上传 / 下载 / 权限管理 → [`lark-drive`](../lark-drive/SKILL.md)。复制文档、创建副本或另存为副本时,按其指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文。 +- **文档评论**:添加、查看、回复评论或增删 reaction → [`lark-drive`](../lark-drive/SKILL.md)。 diff --git a/skills/lark-doc/references/genres/business-analysis.md b/skills/lark-doc/references/genres/business-analysis.md new file mode 100644 index 0000000000..ca92a9b9f1 --- /dev/null +++ b/skills/lark-doc/references/genres/business-analysis.md @@ -0,0 +1,30 @@ +# Genre Contract: Business Analysis / 商业分析 (`report.business_analysis`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 结论前置、具体、条件化;模型只用于改变比较或暴露约束,不用管理黑话代替判断 | +| 内容逻辑 | 围绕一个具体决策,比较现状 / 不行动与真实替代项;用统一目标和口径评价价值、全周期成本、风险、约束与可实施性,给出推荐、暂缓或验证门及翻转条件 | +| 事实 / 边界 | 事实、估算、假设、未知和外部依赖分开;数字标来源、时点、单位、口径和置信范围;利益相关方、不可货币化影响和权限边界显著;分析建议不等于批准或承诺 | +| 错误 | 为预选方案找论据;无现状基准或真实替代项;口径不一却排名;单一 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 / 目标 / 现状基准 → 选项生成、排除理由与同口径比较 → 关键假设、风险、情景与翻转条件 → 建议为何优于替代 → 阶段门、监测 / 学习计划与未决条件。把现状当真实选项,用区间和场景替代伪精确单点,显著说明谁获益、谁承担成本,以及什么新证据会改变建议。 diff --git a/skills/lark-doc/references/genres/data-report.md b/skills/lark-doc/references/genres/data-report.md new file mode 100644 index 0000000000..f0d7a86fa1 --- /dev/null +++ b/skills/lark-doc/references/genres/data-report.md @@ -0,0 +1,32 @@ +# Genre Contract: Data Report / 数据报告 (`report.data_report`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 准确、可复算、少形容词;标题表达发现、对象和时点,并保留不确定性 | +| 内容逻辑 | 先建立指标契约和可比基线,再回答发生了什么、为何重要、还不能断言什么;观测、解释假设与行动条件分开,限制紧邻相关结论 | +| 事实 / 边界 | 核心指标标定义、单位、分子分母、总体 / 分群、时间窗、来源 / 版本、更新时间和修订状态;比较须同口径,估计须披露可得不确定性,敏感小群体须汇总、抑制或限制访问;数据图须标轴、单位、分母、时点和来源,并提供文字等价信息 | +| 错误 | 只列数字;隐藏分母或口径变化;不可比数据排名;选择性窗口 / 分群;相关性当因果;图轴、单位或来源缺失;统计显著冒充效应大小或业务胜出;伪精确;限制藏在附录 | + +## 适用与消歧 + +解读已定义指标、趋势、分布、漏斗、监控、估计或实验观察值。`有数据`、`有数字`、`分析一下`单独不决定路由;研究问题、抽样和可推广性为主走 [`research-report.md`](research-report.md),比较商业选项走 [`business-analysis.md`](business-analysis.md),组织状态、偏差和下一步走 Workplace 周期报告。 + +## 子类型 + +- KPI / 经营表现与趋势;分群、cohort 与分布;漏斗 / 路径与监控异常。 +- A/B 或实验 readout;设计和推断不足时只能报告观察值,不宣布因果胜出。 +- 预测、估计、修订或统计简报;须标模型 / 假设、适用期和修订状态。 + +## 证据与方法 + +- 保留可复算的基数、过滤、聚合、估计区间和质量说明;比较前核对定义、总体、时间窗、分母和处理方法。 +- 按误解风险同时给绝对值、绝对变化、相对变化和长期基线;不用多余小数位制造精确感。 +- 覆盖、缺失、偏差、口径变化和修订若会改变解释,须与对应发现同处,并说明可能方向、规模和影响。 +- 描述性差异不得写成因果;解释标为待验证假设。统计显著性不等于效应大小、实际重要性或完整决策依据。 +- 缺定义、分母、时间或来源时使用 `[指标定义待核]`、`[分母待核]`,对应值不得进入结论;不可比数据分开展示。核心决策依赖的质量缺口无法关闭时标记 `blocked`。 + +## 结构与高质量写法 + +关键发现与决策限制 → 指标契约 / 数据质量 → 总览与基线 → 必要分维、分布和反例 → 可支持的解释与待验证假设 → 条件式行动 / 验证门 → 方法、修订和来源。每段按“观测 → 基线 / 背景 → 限制 → 含义”推进;复杂图同时给出文字结论和必要精确值,任何视觉不得成为唯一证据。 diff --git a/skills/lark-doc/references/genres/email.md b/skills/lark-doc/references/genres/email.md new file mode 100644 index 0000000000..1fb8b0c99d --- /dev/null +++ b/skills/lark-doc/references/genres/email.md @@ -0,0 +1,38 @@ +# Genre Contract: Email / 邮件 (`platform.email`) + +## 核心定位(硬约束) + +- 交付物是可复制到邮件客户端的邮件成稿,不代表已发送,也不执行收件人查询、邮件发送、草稿箱或邮箱管理;实际邮件操作切到 `lark-mail`。 +- 视觉策略默认使用 `formal`;仅在用户要求且不违反组织规范或所选 content contract 时调整。全文禁止 emoji、高亮块和装饰性组件。 +- 默认只使用短段落、列表和普通链接等基础结构。只有已由用户说明、平台文档或可信配置确认目标平台完整支持飞书富文本时,才允许使用 `rich` 或 rich block;不得根据“邮件”“HTML 邮件”、飞书文档承载或平台名称自行推断支持。 +- 一封邮件只承担一个主要沟通任务;主题、首段、正文和行动请求围绕同一目的。不得编造发件人身份、收件人关系、事实、权限、承诺、截止时间、附件或已完成动作;缺失但必需的信息使用清楚的占位符。 +- 不使用封面或目录。即使已确认平台能力,表格、图片、`callout`、画板及其他 rich block 也只能在信息确有需要时使用,并确保复制、投递和接收后的语义完整。 + +## 适用与消歧 + +用户明确要“写邮件、邮件成稿、邮件草稿、邮件措辞、email、e-mail”,或要求起草回复、跟进、通知、邀约、外联邮件时使用,内容保存在哪里不影响本合同生效。 + +查看、搜索、发送、回复或管理邮箱中的真实邮件属于 `lark-mail` 操作;邮件系统说明、邮件数据分析、营销方案或把邮件作为信息来源时不触发本合同。若任务既要成稿又要实际发送,先按本合同形成并确认内容,再切到 `lark-mail` 执行发送。 + +## 邮件主任务 + +| 主任务 | 内容脊柱 | +|-|-| +| 请求 / 决策 | 目的或结论 → 必要背景 → 明确请求 / 选项 → 期望时间或下一步 | +| 通知 / 同步 | 关键变化 → 影响范围 → 接收方需知 / 需做 → 时间点与联系入口 | +| 回复 / 跟进 | 对应的前情 → 新信息或直接答复 → 未决事项 → 下一步 | +| 邀约 / 外联 | 联系缘由 → 与收件人的相关性 → 具体提议 → 低成本回应方式 | +| 致歉 / 问题沟通 | 承认影响 → 已确认事实 → 补救动作 → 后续安排与边界 | + +## 成稿要求 + +- 成稿先给主题,再给正文;只有用户要求或材料明确时才列出收件人、抄送人等信封字段。主题准确表达对象、事项或所需行动,不使用标题党、空泛寒暄或无信息量的“重要通知”。 +- 称呼依据已知关系和语境选择;关系不明时使用稳妥中性的称呼或显式占位符,不擅自套用亲密、职级或性别称谓。 +- 首段尽快说明来意、结论或与既有线程的关系。背景只保留收件人理解、判断或行动所需的信息,不把完整报告、会议纪要或思考过程原样搬入邮件。 +- 行动请求写清需要谁在何时以何种方式完成什么;材料没有给出负责人或时间时,不自行补造。多个并列事项使用列表,优先让收件人能直接逐项回应。 +- 提及链接、附件或引用材料时说明其用途;未实际提供或上传的材料写成待补占位符,不声称“见附件”。回复和跟进邮件只补充新信息,不机械复述整个线程。 +- 结尾与邮件目的匹配:请求类明确回应方式,通知类说明无需动作或下一节点,外联类保留易于拒绝或调整的空间。署名仅使用已知身份;身份不明时使用占位符,不虚构姓名、团队或联系方式。 + +## 交付前检查 + +确认收件人能从主题和首段判断“为什么收到、需要知道或做什么”,事实、责任人、时间和附件状态均有依据,正文没有无关铺垫或重复,语气符合关系与风险,全文无 emoji;若使用 rich block,已有目标平台支持飞书富文本的确认依据;复制到邮件客户端后仍清晰可读,且未把“成稿”误写成“已发送”。 diff --git a/skills/lark-doc/references/genres/execution-plan.md b/skills/lark-doc/references/genres/execution-plan.md new file mode 100644 index 0000000000..e84872fd39 --- /dev/null +++ b/skills/lark-doc/references/genres/execution-plan.md @@ -0,0 +1,27 @@ +# Genre Contract: Execution Plan / 执行计划 (`workplace.execution_plan`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 以交付和判断为单位,具体、紧凑、可推进;计划可信度来自依赖、产能和验收闭环,不来自章节数量或精确到没有依据的日期 | +| 内容逻辑 | 从已批准结果、成功标准、范围和约束出发,按“交付物 / 工作流 → 依赖与关键路径 → 带退出条件的里程碑 → owner / 接口 / 资源 → 风险触发与备选 → 治理、变更与验收”推进 | +| 事实 / 边界 | 区分已确认承诺、估算、假设和待定项;时间、owner、预算、产能、权限、依赖与验收方须可追溯且算术相容;未批准方向不得写成承诺,关键资源或安全前提未知时收窄计划或 `blocked` | +| 错误 | 任务清单冒充计划、活动无交付物 / 完成定义、里程碑只是日期、排期不服从依赖与产能、所有事项同优先级、接口或验收方缺失、风险无预警信号 / 动作 / owner、变更后不更新基线,任一出现即失败 | + +## 适用与消歧 + +用于方向和目标已定后,组织一次性项目、迁移、发布、活动战役、专项治理或跨团队变更。主要任务仍是选择方向、申请预算 / 资源或授权时走 `proposal.md`;比较策略选项且不形成批准入口走 `business-analysis.md`;发布已授权规则 / 通知走 `formal-doc.md`;重复确定路径走 `sop-tutorial.md`;报当前状态走 `weekly-report.md`。 + +“项目计划、执行方案、实施计划、营销策划”只作召回词。营销策划若仍在决定打法或预算,按上述 Proposal / Business Analysis 消歧;只有已定打法的协同落地走本合同。 + +## 可执行性与证据 + +- 先写可验收结果、范围 / 非范围、约束和最迟决策点;再按交付物而非部门名称拆工作包。每个关键工作包说明 owner、输入 / 输出、依赖、完成定义和验收方。 +- 标出关键路径、可并行项、阶段入口 / 退出条件与资源瓶颈;日期由依赖、产能和必要审批 / 制作 / 校准时间推导。无法推导时用相对时间、区间或具体占位,不补造精确排期。 +- 风险写预警信号、影响、预防 / 响应动作、决策 owner 和备选路径;备选必须说明何时切换及切换后的安全或业务终态,不写“加强沟通”。 +- 治理只保留会产生判断的节奏:接口、升级条件、决策权、范围 / 基线变更和重新验收。密集对应关系可用一张排期、依赖或责任表,但表格不能替代关键路径和取舍说明。 + +## 高质量写法 + +让每个目标能一路回链到交付物、里程碑和工作包,让每个日期能回链依赖与产能,让每个风险能回链触发后的动作。资源不足时缩范围、分阶段或设决策门,不用“全渠道、全覆盖、同步推进”制造伪可行性。 diff --git a/skills/lark-doc/references/genres/formal-doc.md b/skills/lark-doc/references/genres/formal-doc.md new file mode 100644 index 0000000000..2de096db13 --- /dev/null +++ b/skills/lark-doc/references/genres/formal-doc.md @@ -0,0 +1,37 @@ +# Genre Contract: Formal Document / 内部正式材料 (`workplace.formal_doc`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 庄重、准确、简洁、直接;正式性来自真实权威、事实、边界、责任和生命周期,不来自套话或机械层级 | +| 视觉策略 | 固定 `formal`;格式中立的内容稿完成后再应用,禁止高亮块、emoji 和装饰性组件 | +| 允许 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`;禁止装饰色、贴纸、伪红头、伪印章和无证据作用的配图 | +| 内容逻辑 | 先按读者任务选择规则 / 制度、已批准通知 / 安排、检查整改 / 台账或已核定正式说明之一;只写完成该任务所需的对象、依据、要求 / 发现、责任、核验和生命周期,不混写子类型 | +| 事实 / 边界 | 只把已确认的授权、要求、事实和立场写成定论;来源陈述、原始记录、已复核事实和推断分开;外发前确认保密、商业秘密、个人信息、素材权利和发布权限;关键缺口未关闭时不得创建 | +| 错误 | 因“正式”误判公文,把本 leaf 当方案 / 总结 / 简报兜底,伪造批准 / 生效,用通知偷渡未获授权的新规则,网络素材冒充本单位事实,检查线索写成责任结论,或措施与发现不对应,任一出现即失败 | + +## 适用与收口 + +用于把已授权的非公文组织规则或安排、可复核的检查整改记录,或已核定的组织立场写成正式载体,使读者能够判断适用范围、应采取的行动、记录状态或核心立场。 + +待批准方向走 `proposal.md`;复杂一次性执行走 `execution-plan.md`;重复操作步骤走 `sop-tutorial.md`;党政机关公文走 `official-redhead.md`;高层简报走 `memo-brief.md`;周期状态走 `weekly-report.md`;学习总结走 Retrospective / Report。`正式、制度、通知、方案、计划、总结、简报、讲话稿`等词单独不触发本体裁,本体裁也不是不确定请求的 fallback。 + +## 按读者任务选择唯一内容路径 + +| 读者任务 | 内容主线 | +|-|-| +| 判断持续规则 | 目的与权威 → 适用 / 不适用范围 → 必要定义 → 规范要求 → 责任、例外与升级 → 生效、维护、复审和替代 | +| 执行已批准通知 | 发布主体与批准状态 → 受影响对象及范围 → 已确认事项与生效时间 → 动作、责任与期限 → 例外、反馈和联系人 | +| 复核检查整改 | 对象、范围、方法与证据状态 → 每项可观察发现、标准、影响和已支持原因 → 对应措施、责任与期限 → 核验、关闭证据和变更痕迹 | +| 理解已核定立场 | 讲者或发布主体、场合、受众与时长 → 核心立场 → 必要事实和理由 → 期望理解或行动;不混入制度效力 | + +## 证据与高质量写法 + +- 规则类按需写维护责任、版本、批准、生效、复审和替代状态;稳定描述做什么、谁负责、何时生效,易变操作方法链接到受控 SOP。规范词优先沿用组织现有定义,强度不明时标`[规范强度待确认]`。 +- 检查整改区分用户陈述、原始记录、已复核事实和待补证线索;关键日期、数量或结论证据不足时就近标`[证据待补:补证动作]`,不得推断原因或责任;归档补正保留原记录。 +- 检查措施必须对应具体发现并可核验;已批准通知只传达授权范围内的事项;正式讲话只使用已核定立场,并按真实语速朗读校验。 +- 使用主动句、明确主体和一致术语,一句只表达一个事实、判断、要求或许可;清单严守用户指定数量与字段,不机械补背景或文控字段。 +- 批准者、依据、权限、适用范围、生效状态或发布条件不明时使用具体占位并保持草案;不得以版式、标题或署名暗示已经批准、签发或生效。 diff --git a/skills/lark-doc/references/genres/meeting-minutes.md b/skills/lark-doc/references/genres/meeting-minutes.md new file mode 100644 index 0000000000..f74bef3047 --- /dev/null +++ b/skills/lark-doc/references/genres/meeting-minutes.md @@ -0,0 +1,24 @@ +# Genre Contract: Meeting Minutes / 会议纪要 (`workplace.meeting_minutes`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 中性、精确、按议题和决定组织,用稳定标签区分决定、建议、未决和待确认,不重放发言顺序 | +| 内容逻辑 | 先说明会议身份和记录状态,再按议题写实际材料 / 必要讨论摘要 → 决定及理由 / 异议 → 未决项 → 行动 → 审阅材料;深度与治理风险相称 | +| 事实 / 边界 | 出席、法定人数、冲突、动议、表决、决定、owner、期限和批准状态均须来自会议材料或确认;只保留治理所需个人信息,草稿不得冒充批准版;历史状态固定为文字快照,不得由`checkbox`、`task`等可变交互块改写 | +| 错误 | 摘要冒充逐字稿、讨论流水账、建议写成决定、行动不可跟踪、法定人数不明却宣称决定有效、草稿冒充批准、静默改历史或泄露无关个人信息,任一出现即失败 | + +## 适用与消歧 + +用于某次已发生会议的可引用治理记录,使缺席者、执行者和审核者确认决定、未决与行动。逐字 / 逐发言人 / 可回放内容只是 transcript 源材料;会前准备走 `memo-brief.md`;非会议状态走 `weekly-report.md`;党政机关法定“纪要”走 `official-redhead.md`。 + +## 子类型与治理证据 + +普通工作会可精简为会议身份、决定、未决和行动;项目决策会补必要理由与审阅材料;董事会、委员会、表决或法定会议按章程 / 适用规则记录出席、法定人数、利益冲突、动议、票决、精确决议及认证。 + +证据可来自 agenda、出席记录、实际审阅材料、动议 / 投票和录音 / 逐字稿,但正文只链接关键来源,不复制附件淹没决定。来源冲突并列保留并交主持人 / 参会者确认。 + +## 结构与高质量写法 + +标明名称 / 类型、日期时间、地点 / 方式、主持 / 记录和草稿 / 已批准状态;每个议题围绕结果而非发言顺序。行动项写交付物 / 动作、责任人 / 单位、时间要求和状态。缺失信息用`[决议原文待确认]`、`[owner 待确认]`等具体占位;法定人数或批准不明时不得宣称有效,保持草稿并进入确认流程。 diff --git a/skills/lark-doc/references/genres/memo-brief.md b/skills/lark-doc/references/genres/memo-brief.md new file mode 100644 index 0000000000..53b1b5fa4e --- /dev/null +++ b/skills/lark-doc/references/genres/memo-brief.md @@ -0,0 +1,25 @@ +# Genre Contract: Memo / Brief (`workplace.memo_brief`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 直接、克制、按具名读者控制信息密度,首屏给结论、状态或 ask,不设固定篇幅 | +| 内容逻辑 | 先选信息、决策或会前三种模式之一,再按“核心事项 / ask → 必要事实 → 影响 / 取舍 → 风险 / 未知 → 动作”推进;只有真实选择才写选项 | +| 事实 / 边界 | 事实、数字、立场、审批状态和时点均须可核验;未知与假设就近标记;Memo 只可作完整 Proposal 的决策封面,不替代其论证 | +| 错误 | 首屏无结论或 ask、把完整 Proposal 压成摘要、编造审批 / 立场、用固定篇幅删证据、细节不解释影响,任一出现即失败 | + +## 适用与消歧 + +用于让具名内部读者快速知悉、判断或完成会前准备。请求批准完整方向、预算、资源或执行承诺走 `proposal.md`;按周期判断相对目标的位置走 `weekly-report.md`;“摘要 / 简报”单词本身不触发本体裁。 + +## 子类型与证据 + +- 信息 Brief:变化 → 影响 → 当前状态 / 风险 → 下一步;无须行动时明确“仅供知悉”。 +- 决策 Memo:决定事项 / 时点 → 现状 → 真实选项及同口径影响 → 推荐与证据 → 明确决策入口。 +- 会前 Brief:会议目标 → 已核验的参与方立场 / 利益 → 要点与禁区 → 期望结果;未知立场不得补造。 +- 按需标读者、作者 / 责任团队、日期和信息截至时间。持续更新时说明相对上版的变化及下次更新点。 + +## 结构与高质量写法 + +按重要性而非材料顺序组织,一个段落一个观点;关键判断不藏在附件。建议写清谁做什么、为什么以及怎样判断完成,并呈现足以改变判断的风险、反例和不确定性。缺关键事实时用`[关键结论待确认]`、`[数据口径待核]`等具体占位,或收窄为待核问题清单;仍要求据此批准时必须 `blocked`。 diff --git a/skills/lark-doc/references/genres/official-redhead.md b/skills/lark-doc/references/genres/official-redhead.md new file mode 100644 index 0000000000..589ad684fa --- /dev/null +++ b/skills/lark-doc/references/genres/official-redhead.md @@ -0,0 +1,73 @@ +# Genre Contract: Official Document / 公文内容稿 (`workplace.official_redhead`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 庄重、准确、简洁、直接;禁网感、营销话术、情绪化评价、空话和机械编号 | +| 视觉策略 | 固定 `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`;禁装饰色、伪红头和伪印章 | +| 内容逻辑 | 按行文目的、机关关系和受众确定唯一文种,再按“必要依据 / 缘由 → 核心事项 / 决定 → 可执行要求 → 必要结语”推进 | +| 事实 / 边界 | 只写已给定或已核验的事实、依据、权限和决定;未知项具体占位,关键缺口未关闭时不得创建;飞书只交付内容审校稿,不宣称已签发或生效 | +| 错误 | 禁止文种或行文关系错误、报告夹请示、请示一文多事 / 多头主送、批复无对应请示、引用 / 文号 / 序号 / 附件不规范,以及编造事实、依据、权限或制发要素 | + +## 适用 + +仅在明确要求公文、红头 / 套红、正式发文,或法定文种与机关行文关系、发文字号、签发人、主送机关等制发要素共同出现时使用。“红头文件”是制发信号,不是文种;普通公司通知、制度、检查 / 整改材料走 `formal-doc.md`,普通会议记录走 `meeting-minutes.md`。 + +## 文种选择 + +按“行文目的 → 发文与受文机关关系 → 受众范围”判断,不按单个关键词判断。 + +| 文种 | 适用意图 | +|-|-| +| 决议 | 会议讨论通过重大决策 | +| 决定 | 对重要事项作出决策部署、奖惩或变更 / 撤销决定 | +| 命令(令) | 公布法规规章、施行重大强制措施、授予衔级或嘉奖 | +| 公报 | 权威公布重要决定或重大事项 | +| 公告 | 向国内外宣布重要或法定事项 | +| 通告 | 在一定范围公布应遵守或周知的事项 | +| 意见 | 对重要问题提出见解和处理办法 | +| 通知 | 要求下级 / 有关单位执行或周知,批转、转发公文 | +| 通报 | 表彰、批评、传达重要精神或告知重要情况 | +| 报告 | 向上级汇报工作、反映情况或答复询问,不请求决定 | +| 请示 | 向上级请求指示或批准;一文一事,原则上只主送一个上级机关 | +| 批复 | 答复下级机关请示,必须有对应来文 | +| 议案 | 政府依法向同级人大或其常委会提请审议 | +| 函 | 不相隶属机关间商洽、询答、请求批准或答复审批 | +| 纪要 | 记载正式会议主要情况和议定事项,不写逐字过程 | + +优先消歧:汇报且不求决定用报告,求上级决定用请示,不相隶属机关商洽用函;面向明确单位执行用通知,面向一定范围不特定对象遵守用通告,向国内外宣布重要 / 法定事项用公告,传达情况或评价用通报。 + +## 行文与事实 + +- 按隶属关系、职权和授权行文;一般不越级,特殊越级时同时抄送被越过机关。 +- 上行文原则上主送一个上级机关,不抄送下级;报告不得夹带请示。除直接交办外,不主送上级负责人个人。 +- 一份主文保持一个行文方向和授权状态;同一事项若既需向上请求批准又需向下要求执行,应拆分文稿或待批准后另行制发,附件不得偷渡尚未授权的执行要求。 +- 下行要求不得超出发文机关权限;涉及其他地区 / 部门职权时先协商。联合行文仅限必要且主体关系适当的情形。 +- 只把已确认的决定写成指令。措施按需写明主体、动作、对象、期限、标准和反馈去向;对不相隶属机关使用`商请`、`请予`、`函复`等匹配关系的措辞。 +- 缺少授权、关键依据、核心事实、适用范围或审批决定时不得发布;不得猜测文号、签发人、密级或紧急程度。 + +## 内容结构 + +- 标题一般使用“发文机关 + 事由 + 文种”,内含法规、规章或被印发文件名称时使用书名号。 +- 主送机关使用全称、规范简称或同类机关统称。附件说明与附件顺序、名称逐字一致;多个附件用阿拉伯数字编号,名称末尾不加标点。 + +| 文种 | 常用结构 | +|-|-| +| 通知 | 缘由 / 依据 → 事项 → 对象 / 时间 → 已确认要求 | +| 请示 | 缘由 / 依据 → 单一请示事项与倾向意见 → `妥否,请批示` | +| 批复 | 准确引用来文 → 明确意见 → 执行要求 → `此复` | +| 函 | 事项 / 依据 → 商请或答复 → `请予函复` / `特此函复` | +| 报告 | 情况 → 事实 / 成效 → 问题 → 后续安排 → `特此报告` | +| 纪要 | 会议基本信息 → 主要情况 → 议定事项 / 责任 / 时限 | + +## 文号、引用与序号 + +- 普通发文字号采用“机关代字 + 完整年份 + 顺序号”,如 `×政发〔2026〕8号`;年份用六角括号,顺序号不加“第”、不编虚位。命令(令)的令号可用 `第×号`。 +- 首次引用其他公文时写完整标题和文号:`《××机关关于印发〈××办法〉的通知》(×发〔2026〕8号)`。不只写文号,不用论文式参考文献编号。 +- 文件、法律法规名称使用书名号;直接引文使用中文双引号,内层用单引号。引文须核对原文、效力、制定机关和适用范围;无法核实则标记 `[引文待核]`。 +- 正文层级依次使用 `一、`、`(一)`、`1.`、`(1)`,不得写成 `1、`、`(一)、`,不得跳级;超过四级时重组内容。 +- 成文日期写为 `2026年7月13日`,月日不补零。标点和数字按 GB/T 15834、GB/T 15835 使用;全称及规范简称前后一致。 diff --git a/skills/lark-doc/references/genres/prd.md b/skills/lark-doc/references/genres/prd.md new file mode 100644 index 0000000000..b1e3c5bf7e --- /dev/null +++ b/skills/lark-doc/references/genres/prd.md @@ -0,0 +1,26 @@ +# Genre Contract: PRD / 产品需求 (`workplace.prd`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 具体、行为化、术语和状态一致;不用固定大模板制造完整感 | +| 视觉约束 | 在有明确内容作用时用场景、状态流、表格、图示等组件降低理解与验收成本,但不得替代需求、证据或验收 | +| 内容逻辑 | 方向已定后按“用户问题 / 证据 → 目标 / 结果 → 范围 / 非目标 → 场景 → 行为需求 / 验收 → 异常 / 边界 → 适用质量约束 → 依赖 / 开放问题”推进 | +| 事实 / 边界 | 用户需要、指标、研究、阈值、可行性、owner、状态和排期须可追溯;需求描述可观察结果,acceptance criteria 验结果;安全、隐私、无障碍等仅按实际风险和标准纳入 | +| 错误 | 功能清单无用户问题、Proposal 论证吞没需求、范围 / 非目标缺失、需求暗藏实现、验收不可测、正常路径无异常、伪造研究 / 阈值 / 批准或机械填质量模板,任一出现即失败 | + +## 适用与消歧 + +用于方向与投入原则已定后,让产品、设计、研发和测试就用户问题、范围、可观察行为与完成标准形成共识。是否立项 / 选择方向 / 批资源走 `proposal.md`;架构、接口和实现取舍走 `technical-doc.md`;已批准重复操作走 `sop-tutorial.md`。“需求 / 功能”单词本身不触发。 + +## 证据与需求写法 + +- 明确目标用户、任务情境、问题及研究 / 行为 / 支持证据;内部偏好和预设功能不冒充用户需要。 +- 产品目标连接可观测结果,指标标口径、来源和时间窗。未知目标值用`[目标值待产品 / 数据确认]`并给确认 owner / 时点,不编使用量或阈值。 +- 关键需求写成 actor + trigger / precondition + observable outcome + failure / edge;术语和状态一致。用户故事格式只是工具,不是章节配额。 +- 每个质量约束给可验证门槛或明确待确认项;不适用时不填模板。需求、验收 / 测试和来源保持追踪。 + +## 结构与高质量写法 + +先定范围、非目标、优先级、依赖、假设和开放问题,防止 scope creep;再按关键场景写正常、异常和边界行为。把大而不可测的需求拆到可验收粒度,不用“体验更好 / 性能高”等形容词。没有用户证据时收窄为假设和研究计划;关键合规 / 安全门缺失时 `blocked`,开放问题不得藏在脚注。 diff --git a/skills/lark-doc/references/genres/proposal.md b/skills/lark-doc/references/genres/proposal.md new file mode 100644 index 0000000000..64e454a67f --- /dev/null +++ b/skills/lark-doc/references/genres/proposal.md @@ -0,0 +1,24 @@ +# Genre Contract: Proposal / 方案提案 (`workplace.proposal`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 结论前置、具体、可审议,主动呈现代价、反例与不确定性,不用宏大背景或伪精确制造可批准感 | +| 内容逻辑 | 明确 decision / 决策者 / 时点,再按“改变理由与不行动基准 → 目标 → 真实选项同口径比较 → 推荐 → 资源 / 交付 → 风险 / 未知 → 决策入口”推进 | +| 事实 / 边界 | 区分事实、估算、假设和未知;收益、成本、资源、用户证据、审批和排期须可追溯;进入执行决策才写治理 / 退出条件,未批准不得写成既有承诺 | +| 错误 | 无决策者 / ask、无不行动基准、预设单一答案、选项口径不同、成本风险后置、未批先承诺、编造收益 / 审批或与 PRD 混写,任一出现即失败 | + +## 适用与消歧 + +用于请求具名决策者批准、驳回或选择方向、预算、资源、试点或执行承诺。方向已定并定义产品行为 / 验收走 `prd.md`;短决策封面走 `memo-brief.md`;已批准安排的发布走 `formal-doc.md`。“方案”单词本身不触发本体裁。 + +## 子类型与证据 + +可用于概念 / 方向、投资 / 预算、资源申请、变更、试点 / 实验和执行承诺提案;深度随阶段、金额、风险和不可逆性裁剪。必须给 case for change、目标 / 成功标准、不行动或最小变化基准,以及足以判断的成本、收益、依赖、风险和敏感因素。 + +存在真实选择时纳入可行替代并以相同范围、时间和评价标准比较;没有真实替代时说明约束如何收敛,不能造假选项。不可量化影响可定性,但须说明原因及其决策影响。 + +## 结构与高质量写法 + +先把选择题写对,再论证推荐;显式记录被放弃选项和推荐代价。数字不足时使用范围、依据和验证计划,不补精确点估。进入执行决策时按需补 owner、里程碑、治理、衡量、退出 / 复盘;缺决策权、关键成本或安全合规依据时收窄为探索稿,仍要求批准则 `blocked`。 diff --git a/skills/lark-doc/references/genres/research-report.md b/skills/lark-doc/references/genres/research-report.md new file mode 100644 index 0000000000..02e98f216d --- /dev/null +++ b/skills/lark-doc/references/genres/research-report.md @@ -0,0 +1,32 @@ +# Genre Contract: Research Report / 调研报告 (`report.research_report`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 证据驱动、校准、术语一致;摘要独立可读,语气强度不得超过证据强度 | +| 内容逻辑 | 先确定研究问题与研究类型,再交付当前答案、证据强度和可推广边界;按问题或主题组织发现,解释、建议和验证计划必须回链发现 | +| 事实 / 边界 | 区分原始事实或参与者陈述、分析推断、假设和建议;方法披露足以评估偏差;适用时确认委托、利益、同意、匿名或保密、敏感数据用途;研究材料须确认使用权、去标识、来源与说明,复杂视觉附文字等价信息;未知不补造 | +| 错误 | 无明确问题;方法黑箱;资料摘要冒充发现;样本外推;醒目个案冒充模式;事实、解释和建议混写;相关写成因果;合规状态、授权或行业共识靠猜 | + +## 适用与消歧 + +以明确研究问题、研究设计或材料、发现和限制为主要交付。`调研`、`研究过`、`访谈`、`问卷`单独只用于召回;只解读既定指标走 [`data-report.md`](data-report.md),比较特定战略或资源选项走 [`business-analysis.md`](business-analysis.md),方法并非判断重点的问题框架综合走 [`white-paper.md`](white-paper.md)。 + +## 子类型 + +- **定量 / 定性 / 混合研究**:根据问题选择总体、抽样或招募、工具、采集和分析方法;不用一种方法的规范冒充全部研究标准。 +- **用户研究 / 项目或政策评估**:说明场景、参与者、干预或对象、成功标准、观察窗口和用途。 +- **证据综合**:只有检索范围、纳排和综合方法明确时才作为研究发现;普通资料汇总不得升级为系统结论。 + +## 证据与方法 + +- 明确对象、用途、非目标和适用情境;按需披露委托 / 执行方、总体与纳排、抽样 / 招募、样本量、工具 / 题项、采集方式 / 语言 / 时点、响应 / 脱落、加权、编码 / 分析和质量控制。 +- 写明偏差、缺失、反例、负结果、替代解释及其可能方向;透明报告不等于设计无偏,也不证明结论可复现。 +- 人员或敏感研究在适用规则下确认知情同意、撤回与伤害风险、匿名 / 保密、访问和数据用途;未获授权不公开可识别材料、原始数据或代码。 +- 引文只说明有出处的体验或机制,不把单个引文写成频率;结论只推广到设计和样本支持的人群、时间与环境。 +- 无原始材料只能产出研究范围或计划,不能生成 findings;方法或样本缺失时用 `[抽样方法待核]`、`[采集时点待核]` 并收窄为探索性观察。关键伦理、授权或方法缺口会改变结论时标记 `blocked`。 + +## 结构与高质量写法 + +独立答案、证据强度与关键限制 → 问题 / 范围 / 既有知识 → 方法 / 样本 → 按问题或主题组织的发现 → 解释、反例与替代解释 → 有边界的建议 / 验证 → 局限、来源与必要附录。摘要覆盖目的、方法、发现、含义和限制;正文以“主张 → 证据 → 限定”推进,不按作业时间线罗列过程,不用组件数量代替研究质量。 diff --git a/skills/lark-doc/references/genres/retrospective.md b/skills/lark-doc/references/genres/retrospective.md new file mode 100644 index 0000000000..25926de72b --- /dev/null +++ b/skills/lark-doc/references/genres/retrospective.md @@ -0,0 +1,25 @@ +# Genre Contract: Retrospective / 复盘 (`workplace.retrospective`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 坦诚、无责备、因果克制,围绕证据和下一轮改变,不用“加强沟通 / 持续关注”代替可验证实验 | +| 内容逻辑 | 界定已结束周期 / 事件,再按“目标 / 证据 → 预期与实际 → 聚类观察 → 洞见 / 待验证因果 → 保留项 → 少量改进实验 → 复查”推进 | +| 事实 / 边界 | 事实 / 观察、解释 / 假设、洞见和行动分层;结论回链事件、指标或交付物;根因仅在证据充分时声明,否则写可证伪假设;保护必要隐私 | +| 错误 | 周报换标题、成绩陈列 / 情绪宣泄、个人归罪、单一根因臆测、行动无 owner / 验证 / 跟踪、不回看上轮或把模板便签当结论,任一出现即失败 | + +## 适用与消歧 + +用于回看明确迭代、阶段、项目或事件,形成可复用学习并改变下一轮做法。当前状态与升级需求走 `weekly-report.md`;仍在未知中止损、取证、恢复或调查生产事故走 `technical-doc.md`。生产事故可由 Technical 主文承载影响 / 时间线 / 根因 / 恢复,再附本体裁的团队学习层。 + +## 证据与因果 + +- 开头界定范围、时间、目标 / 原计划、参与视角和已知证据;不得补造指标、时间线、共识、原因或行动。 +- 同时识别应保留与应改变的条件,按影响聚类;以系统、流程、工具、接口和当时条件为对象,不把惩罚叙事冒充根因。 +- 个人工作心得 / 成长反思以一个真实事件或转折为证据,呈现“当时判断 → 反证 / 后果 → 新认识 → 下一次可观察行为”;不代写材料没有提供的情绪、动机、心路或成长。 +- 证据不足时写“促成条件 / 假设 + 验证方式”,不能用确定语气。缺基线用`[基线待补]`,涉及安全 / 法务而证据不足时转 Technical 并 `blocked`。 + +## 结构与高质量写法 + +便签、4Ls、Start / Stop / Continue 只是收集手段,成稿须综合为主题和判断。改进实验写动作、owner、目标时间、验证条件和跟踪位置,优先改变系统而不是要求人“更小心”;按需补上轮行动效果和下轮复查点。项目收尾可增加成本、范围、相关方和知识移交,但不机械扩章。 diff --git a/skills/lark-doc/references/genres/route-consumer.md b/skills/lark-doc/references/genres/route-consumer.md new file mode 100644 index 0000000000..48c334340d --- /dev/null +++ b/skills/lark-doc/references/genres/route-consumer.md @@ -0,0 +1,37 @@ +# Genre Contract: Consumer / 消费决策内容 (`router.consumer`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 具体、可信、可亲近,体验感服务于选择,不用热情语气替代测试、价格和适用条件 | +| 内容逻辑 | 围绕具体消费场景,用“需求 / 使用条件 → 评价标准 → 体验或测试证据 → 权衡 → 适合谁 / 不适合谁”推进;合集和比较须共享标准,不按品牌逐段堆卖点 | +| 事实 / 边界 | 只声称真实体验或有方法支撑的测试;披露赠品、佣金、赞助和其他重要关系;标明版本、时间、价格口径及限制;用户 / 专家引语、图片和前后对比须有授权、来源、语境与真实性依据,非文字证据须有文字等价信息;遵守目标法域和平台当期消费者、广告与高风险品类规则,关键利益关系、核心功效、安全条件或报价条款缺失时 blocked | +| 错误 | 编造使用经历、未披露商业关系、无方法的评分 / 排名、把主观偏好写成客观最佳、隐藏不适用人群或总成本、用极端个案概括功效、过期信息仍当现状,任一出现即失败 | + +## 适用与消歧 + +用于帮助读者购买、比较、避坑或判断某种生活方式是否适合自己。仅出现小红书、微信等平台名不触发;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,消费选择任务作为该 leaf contract 的硬约束,不再并读 Consumer。以公共事件核实为主走 Media,以价值判断为主走 Opinion,以品牌拥有的转化内容走 Marketing。 + +“测评”必须继续区分独立比较、真实个人体验和品牌演示:前两者可走本合同,品牌控制结论或行动入口时走 Marketing,并保留显著披露。 + +## 子类型 + +| 子类型 | 读者任务与推进 | +|-|-| +| 单品体验 / 好物分享 | 判断某物在真实场景是否值得;使用背景 → 观察 → 优缺点 → 适用人群 | +| 对比测评 / 排名 | 在同一任务下选择;方法与样本 → 共同标准 → 结果 → 权衡与不确定性 | +| 合集 / 清单 | 快速缩小候选范围;选择门槛 → 分组理由 → 各项差异 → 最终选择路径 | +| 探店 / 服务体验 | 判断是否到访或购买服务;时间地点 → 实际流程 / 价格 → 体验证据 → 限制 | +| 生活方式内容 | 判断实践成本与可复制性;目标 → 做法 → 真实投入 / 结果 → 适用边界 | + +## 证据、披露与合规 + +- 第一人称体验交代使用时长、频率、版本和条件;未亲测就明确资料来源,不伪装成亲历。比较结论说明样本、标准、测量方法及未覆盖变量。 +- 把“真实 / 有效”“适合当前读者”“值得当前价格”分开判断。强参数品只保留会改变选择的指标,并解释版本口径和决策影响;使用评分 / 排名时公开标准、权重、主观边界和反转条件,安全或资格等一票否决项不得被平均分稀释。 +- 商业关系和激励在读者接触推荐时清楚出现,不能藏在模糊标签或文末。披露、重大限制和安全警示须就近可见,不能只藏在链接或视觉装饰中。 +- 健康、安全、金融、未成年人等高风险内容只写证据支持且适用法域允许的范围;不能核实的功效或个体化建议删除。规则冲突时按交付地区、渠道和发布时间核验,不把单一国家指南写成全球义务。 + +## 结构与高质量写法 + +先告诉读者评判基准,再给结论,才能让“推荐”可复核。优点与代价写在同一决策语境内,价格同时说明时间、地区、规格和附加成本。结尾给条件化选择,而不是人人适用的口号;关键参数待补时用具体占位并暂停对应结论,不能靠语气填空。 diff --git a/skills/lark-doc/references/genres/route-creative.md b/skills/lark-doc/references/genres/route-creative.md new file mode 100644 index 0000000000..d1ecaf17ba --- /dev/null +++ b/skills/lark-doc/references/genres/route-creative.md @@ -0,0 +1,36 @@ +# Genre Contract: Creative / 叙事创作 (`router.creative`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 语言、节奏和视角服务指定叙事体验,表达自由不替代人物动机、因果和场景可读性 | +| 内容逻辑 | 本合同只覆盖叙事创作;先确认体验、篇幅、视角和约束,用“人物欲望 → 阻力 → 选择 → 代价 → 变化”形成场景因果;剧本和互动叙事分别服从可演行动与有后果分支 | +| 事实 / 边界 | 用户授权的虚构可创造,但真实背景、引用、既有作品正史和人物身份不得伪造;图片、题记、原作片段和创作参考须遵守来源与使用权限,非文字参考及互动分支图须附文字等价的关系、路径和状态说明;区分明确设定、合理创作补白和待确认约束;核心世界观 / 权利边界冲突且无法安全收窄时 blocked | +| 错误 | 只堆设定不发生选择、人物为推进情节突然失去动机、冲突靠偶然或外力无代价解决、视角 / 时态无意漂移、剧本写成解释性小说、互动分支无状态差异、把诗歌静默纳入交付,任一出现即失败 | + +## 适用与消歧 + +仅用于网文、短篇故事、同人叙事、互动小说、剧本和故事大纲等以事件、人物选择和变化为核心的创作。诗歌、歌词、纯抒情散文不在本合同范围;收到这类请求时先确认目标或使用相应专用规则,不因“Creative”一级名称而静默扩写。 + +以论点和证据表达判断走 Opinion;以真实个人经历建立专业信誉走 Personal Brand。世界观说明若目标只是知识解释,不因带角色名就成为故事。 + +## 子类型 + +| 子类型 | 读者任务与推进 | +|-|-| +| 短篇 / 网文 | 获得连续叙事体验;触发变化 → 升级阻力 → 关键选择 → 后果 / 回响 | +| 同人叙事 | 在约定正史与角色核心上体验新情境;明确时间点 / 偏离点 → 角色选择 → 新后果 | +| 剧本 / 短剧 / 叙事短视频 | 看见可拍、可演的行动与冲突;媒介 / 时长 / 制作约束 → 场景目标 → 视觉、行动、声音 / 对话 → 转折 → 场景状态变化 | +| 互动小说 | 作出有信息依据且有后果的选择;状态 → 选择 → 反馈 → 状态改变 → 后续分支 | +| 故事大纲 | 判断故事能否成立并继续创作;前提 → 人物弧 → 节点因果 → 高潮选择 → 结局变化 | + +## 设定与真实性 + +- 先锁定用户给定的人物、关系、禁区、正史时间点和期望体验;未指定的创作空间可以补白,但不得覆盖明确约束。关键歧义有多个会显著改变成品的方向时先询问。 +- 使用真实地点、历史、科学或文化材料时核验会影响情节的事实;有意架空应让读者能辨认其虚构约定。同人创作不把自设冒充正史,也不虚构原作引语。 +- 真实人物、未公开经历、受保护素材和委托作品按授权边界处理;不能确认可用性时改为原创替代或保持 blocked。 + +## 结构与高质量写法 + +每个场景都让人物为目标采取行动,并在离场时改变信息、关系、资源或风险。细节同时承担感官、人物或伏笔功能,背景通过当前冲突释放,不集中讲解。对话要改变局面而非重复旁白;剧本动作、声音和调度须在声明的演员、场地、道具与媒介条件下可实现,不用固定“前三秒 / 每分钟一反转”公式代替因果。结局兑现前文建立的选择与代价。大纲可显式呈现结构,成稿则把结构转化为可体验的场景。 diff --git a/skills/lark-doc/references/genres/route-knowledge.md b/skills/lark-doc/references/genres/route-knowledge.md new file mode 100644 index 0000000000..57cee81d5b --- /dev/null +++ b/skills/lark-doc/references/genres/route-knowledge.md @@ -0,0 +1,39 @@ +# Genre Contract: Knowledge / 知识与教程 (`router.knowledge`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|--| +| 写作风格 | 具体、可操作、按读者水平解释;科普可生动、有好奇心,但不得牺牲准确性或编造戏剧性 | +| 内容逻辑 | 先选择唯一主模式和读者起点,再承诺一个理解、学习、一次操作、检索或选择结果;第一屏给适用对象、目标和关键前置,概念、步骤、练习 / 验证、反馈与例外按需渐进展开 | +| 事实 / 边界 | 事实、版本、命令、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`。 + +## 高质量写法 + +第一屏让读者知道能理解、学会、完成或找到什么;用读者语言、具体动词和可验证结果推进,每节只增加必要的新理解或动作。先给最短可行路径,再在需要处补原理、变体和进一步阅读;示例只服务迁移,不扩张为用户未要求的全套内容,也不用丰富组件掩盖解释不足。 diff --git a/skills/lark-doc/references/genres/route-marketing.md b/skills/lark-doc/references/genres/route-marketing.md new file mode 100644 index 0000000000..b991317fff --- /dev/null +++ b/skills/lark-doc/references/genres/route-marketing.md @@ -0,0 +1,40 @@ +# Genre Contract: Marketing / 营销与公关 (`router.marketing`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 清楚、有吸引力且可行动,表达强度不得超过承诺、证据和授权,紧迫感不得制造误导 | +| 内容逻辑 | 先锁定受众、漏斗阶段和唯一主要读者结果;转化内容用“场景 / 问题 → 有边界的价值主张 → 证据 → 关键条件 / 异议 → 一个 CTA”推进,公关稿按已授权事实、相关方影响、组织回应和后续更新推进 | +| 事实 / 边界 | 所有客观、比较、功效和稀缺性主张发布前有相称证据;价格、资格、期限和限制就近可见;广告身份与商业关系按目标法域 / 平台规则披露;评价、案例、引语、图片和活动素材须真实、可核且获授权,非文字证据须有文字等价信息;核心主张证据、适用法域、发布授权、关键交易条件或任务要求的行动入口缺失时 blocked | +| 错误 | 无证据的“最佳 / 保证 / 第一”、隐藏限制或自动续费、伪造倒计时 / 库存 / 评价、把广告伪装成独立报道、未经授权承诺赔付或责任、转化内容多个 CTA 争抢、用复杂 block 掩盖价值缺口,任一出现即失败 | + +## 适用与消歧 + +用于组织拥有或授权、目标是认知、转化、留存或公共关系管理的内容。由新闻机构独立选题、核实和报道的内容走 Media;组织自有新闻稿、媒体通稿、品牌声明和回应口径走 Marketing,即使采用新闻结构也不变成独立报道。 + +个人真实体验用于帮助消费选择时走 Consumer;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,营销目标、商业关系和交易条件作为该 leaf contract 的硬约束,不再并读 Marketing。出现“新闻稿、软文、活动文案”只作召回信号,仍须确认发布主体、受众、行动和商业关系。 + +内部营销策划、增长方案或活动执行计划不因“营销”进入本合同:比较打法走 Business Analysis,请求预算 / 资源 / 战役批准走 Proposal,已定打法的协同落地走 Execution Plan;只有最终面向受众的传播、招募或转化成稿走 Marketing。 + +## 子类型 + +| 子类型 | 读者任务与推进 | +|-|-| +| 广告 / 短文案 | 迅速判断是否值得行动;受众场景 → 单一利益 → 可信理由 → 条件 → CTA | +| 详情页 / 落地页 | 完成比较与转化;价值主张 → 关键能力 → 证据 → 方案 / 条款 → 异议 → CTA | +| 活动 / 私域话术 | 判断是否参与并知道下一步;对象 → 收益 → 时间地点 / 门槛 → 风险限制 → 行动 | +| 新闻稿 / 媒体通稿 | 获取组织已授权消息;可发布事实 → 为什么重要 → 引语 / 背景 → 联系与更新安排 | +| 声明 / 危机回应 | 理解已知事实和组织行动;事件范围 → 已确认影响 → 当前措施 → 未知项 → 下次更新时间 | + +## 证据、授权与合规 + +- 建立“主张—证据”对应:定量效果说明口径、样本和时间,比较主张保证对象与标准可比;图片、引语、评价和案例保留来源、必要语境及授权记录。 +- 披露和限制应让普通受众在作决定前看见并理解,不能由链接、模糊缩写或弱提示代替。规则随法域、媒介、品类和时间变化,交付前核验当期法律、监管与平台要求。 +- 公关内容只写已获授权的事实和承诺;事故原因、责任、补偿、调查结论未核定时明确 unknown。关键批准或法律审阅未完成,不生成可直接外发版本。 + +## 结构与高质量写法 + +价值主张具体到受众、场景和结果,证据紧跟对应主张。文案须锚定品牌独有资产、产品细节或品类语境;换成竞品名仍成立就返工。多版本应改变受众状态、主张、证据或场景并说明选择条件,不做同义改写。 + +删除不改变理解或行动的品牌空话,不把真实痛点升级为羞耻、身份不足或恐惧操控。有转化目标时,次级入口均服务同一主要行动;优惠资格和截止时间采用可比较字段,待补价格、库存或链接用语义化占位,并让受影响结论保持 blocked。 diff --git a/skills/lark-doc/references/genres/route-media.md b/skills/lark-doc/references/genres/route-media.md new file mode 100644 index 0000000000..6ff646499d --- /dev/null +++ b/skills/lark-doc/references/genres/route-media.md @@ -0,0 +1,36 @@ +# Genre Contract: Media / 资讯媒体 (`router.media`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 准确、中立、紧凑,信息密度服从读者快速理解,不用戏剧化措辞替代事实强度 | +| 内容逻辑 | 先确定快讯 / 报道、解释、人物特写或访谈的读者任务;关键信息优先,随后给证据、必要背景、相关方视角和仍未知事项,段落按重要性、因果或时间关系推进 | +| 事实 / 边界 | 区分已核事实、来源说法、推断和 unknown;准确优先于抢发,关键主张可追溯,负面涉及方获得合理回应机会;引语须忠实可核,图片和原始材料须有使用权限、来源、语境说明及文字等价信息,更正、披露和关键缺口须直接可见;核心事实、来源真实性、发布权限缺失,或严重负面指控尚未提供回应机会时保持草稿并 blocked | +| 错误 | 把组织自有通稿伪装成独立报道、标题超出证据、单一匿名来源承载重大指控、引语失真、事实与评论混写、遗漏重大反方或不确定性、图片无权利 / 来源 / 文字等价信息,任一出现即失败 | + +## 适用与消歧 + +本合同用于以独立采集、核实和公共理解为职责的新闻内容。请求出现“新闻稿、媒体稿、报道”只作召回信号:编辑方能独立核实、选择角度并承担报道判断时走 Media;由组织拥有、批准并面向媒体或公众发布的新闻稿、品牌声明和公关口径走 Marketing。 + +以立场说服为主走 Opinion;以购买决策和亲身体验为主走 Consumer;内部事实简报不因写得像新闻而改变读者任务。渠道名、标题风格或“像媒体一样写”均不能单独触发;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,资讯核实边界作为该 leaf contract 的硬约束。 + +## 子类型 + +| 子类型 | 读者任务与推进 | +|-|-| +| 快讯 / 硬新闻 | 尽快知道发生了什么及其可信程度;核心事实 → 来源与范围 → 必要背景 → 下一确认点 | +| 解释报道 | 理解为什么发生、如何运作及争议在哪里;问题 → 机制 / 时间线 → 多方证据 → 已知边界 | +| 人物 / 特写 | 通过可核场景和经历理解人物或议题;场景 → 关键变化 → 证据与他者视角 → 公共意义 | +| 访谈 / 问答 | 准确获取受访者观点及上下文;交代身份与场景,忠实编辑问答,不补造连接语或立场 | + +## 证据与真实性 + +- 为可能引发争议的事实保留可追溯材料,记录来源身份、接近事实的方式、核实状态和使用限制;匿名只在有公共价值且无法安全具名时采用,并说明读者判断所需的来源范围。 +- 引语逐字可核;压缩、翻译和转述不得改变含义。无法确认的数字、时间、身份或因果就近标明 unknown,不用“据悉”“有消息称”遮蔽来源质量。 +- 开盒、网暴、羞辱、未成年人或其他可能放大伤害的事件只保留理解事实、责任和传播机制所需的最少信息;不为证明热点而复刻身份线索、攻击性内容或未核传言。 +- 更正要说明改了什么;新证据改变核心判断时更新标题和结论。发布前无法核实的核心主张不得靠占位符放行。 + +## 结构与高质量写法 + +标题和导语只承诺正文已证明的内容。每段承担一个信息动作,并在首次出现时交代人物、机构、时间和口径;背景只保留改变理解的部分。多方说法按证据权重而非形式上的各打一板排列,不把可验证事实写成“双方观点”,也不把尚无结论写成确定因果。 diff --git a/skills/lark-doc/references/genres/route-opinion.md b/skills/lark-doc/references/genres/route-opinion.md new file mode 100644 index 0000000000..4e87f0571d --- /dev/null +++ b/skills/lark-doc/references/genres/route-opinion.md @@ -0,0 +1,38 @@ +# Genre Contract: Opinion / 观点评论 (`router.opinion`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 立场鲜明但措辞精确、公平,论证密度高于情绪密度,锋利不等于侮辱或夸张 | +| 内容逻辑 | 明确可争辩的中心判断及其重要性,用理由和证据推进;对有实质争议的主张呈现最强相关反论并回应,结论说明判断边界或行动含义 | +| 事实 / 边界 | 区分事实、推断、价值判断、预测和个人经验;事实可追溯,证据强度匹配主张强度,不把相关性写成因果或把个案外推为普遍规律;引语、图片和作品片段须有可核来源、必要语境与使用权限,非文字证据须有文字等价信息,重要利益关系须显著披露;关键事实缺失时收窄主张,无法成立则 blocked | +| 错误 | 只有态度没有论点、稻草人反驳、选择性证据、人格攻击、标题先定罪、把经验冒充统计、隐藏重大反例或利益关系、结论超出论证,任一出现即失败 | + +## 适用与消歧 + +用于帮助读者评估一个判断、立场或解释框架。事件复述和独立核实走 Media;围绕购买选择的测评走 Consumer;组织为行动或转化发声走 Marketing。出现“评论、专栏、观点”只是召回词,正文必须有可辨认的判断和论证任务。 + +文化评论关注作品、现象的意义和判断;若主要提供剧情复述或故事体验,不走本合同。个人经历可以作为观察入口,但若目标是展示经历与能力,走 Personal Brand。 + +## 子类型 + +| 子类型 | 读者任务与推进 | +|-|-| +| 时评 / 公共议题评论 | 判断事件意味着什么;争点 → 判断 → 证据与机制 → 反论 → 后果 / 建议 | +| 商业 / 行业评论 | 评估策略、趋势或制度;基线 → 驱动因素 → 证据 → 替代解释 → 适用条件 | +| 文化评论 | 理解作品或现象的价值;分析对象 → 解释框架 → 细读证据 → 限度 → 判断 | +| 专栏 / 随笔 | 从观察或经验形成可迁移洞见;具体场景 → 反思 → 关联 → 有边界的结论 | + +## 证据与论证 + +- 开头尽早写出“我主张什么”和“为什么现在值得讨论”,避免用大段背景延迟论点。每个理由回答一个潜在质疑,并由事实、例子、机制或可靠来源支持。 +- 反论选择真正能动摇中心判断的版本,不挑最弱说法;回应可以承认条件、修改范围或解释为何仍不改变结论。观点平衡不是机械分配篇幅。 +- 公共争议先拆清事实真伪、规则 / 权利、价值取舍、责任归属和 unknown,再分别判断;行动建议须对应具体主体、权限 / 义务、可用杠杆与代价,不用“多方协同”抹平责任边界。 +- 预测写明前提和时间范围;价值判断说明采用的标准。涉及他人动机、违法或伤害的判断不得凭语气升级为事实。 + +## 结构与高质量写法 + +段落之间形成“主张 → 理由 → 证据 → 推论”的可追链条,过渡词只标真实关系。文化评论选择一个能统摄正文的主分析轴,把情节、语言、镜头、声音、表演或结构写成“形式选择 → 产生效果 → 支持何种解释 / 评价”的证据链;比较或综述可有多个对象,但不能退化成剧情复述或维度清单,也不把效果直接冒充创作者意图。 + +结尾不复述全文,而是给出经反论校准后的判断、仍然未知的部分,或读者下一步应重新考虑什么。随笔可弱化显式论证标记,但不能牺牲观察与结论之间的可理解联系。 diff --git a/skills/lark-doc/references/genres/route-personal-brand.md b/skills/lark-doc/references/genres/route-personal-brand.md new file mode 100644 index 0000000000..de735b2962 --- /dev/null +++ b/skills/lark-doc/references/genres/route-personal-brand.md @@ -0,0 +1,36 @@ +# Genre Contract: Personal Brand / 个人品牌 (`router.personal_brand`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 可信、具体、有辨识度,声音服从目标读者和真实经历,不用自我评价替代成果证据 | +| 内容逻辑 | 从目标读者和目标机会出发,用“身份 / 价值定位 → 相关经历 → 可验证贡献 → 做事方式 → 下一步意图”组织;每项经历说明情境、本人动作、结果及与目标的关系 | +| 事实 / 边界 | 职位、时间、职责、学历、技能、作品和指标须真实可核,个人贡献与团队成果分开;尊重保密、个人信息、雇主和作品权利,作品、图片和推荐语须确认归属、使用权限与必要语境,非文字证据须有文字等价信息;关键身份、时间、归属或公开权限缺失时用具体占位,无法安全表述则 blocked | +| 错误 | 夸大头衔 / 技能 / 指标、把团队成果全归个人、关键词堆砌、伪造推荐语或客户、泄露敏感信息、作品无归属 / 权限、同一经历前后矛盾、渠道语气改变事实,任一出现即失败 | + +## 适用与消歧 + +用于让招聘方、合作方、客户或专业社群判断“这个人是谁、做过什么、能带来什么”。仅出现平台名称不触发;明确要求最终交付 Email、小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,个人身份、经历和信誉目标作为该 leaf contract 的硬约束,不再并读 Personal Brand。 + +以项目经验得失来改进下一轮走 Retrospective;以购买体验帮助他人选择走 Consumer;以组织身份转化客户走 Marketing。出现“介绍、主页、复盘”不能单独触发,须确认目标是个人能力和信誉呈现。 + +## 子类型 + +| 子类型 | 读者任务与推进 | +|-|-| +| 简历 / CV | 快速判断岗位匹配;摘要 → 相关经历与成果 → 技能 / 教育 → 必要补充 | +| 求职信 / 自我介绍 / 简介 | 理解动机与差异化价值;目标 → 相关证据 → 工作方式 → 明确下一步 | +| 个人主页 | 建立清晰定位并找到入口;一句定位 → 代表证据 → 领域 / 服务 → 联系或作品 | +| 作品集 / 案例集 | 判断能力如何形成结果;问题 → 约束与本人角色 → 过程决策 → 结果与反思 | +| 个人成长回顾 | 理解身份与能力变化;起点 → 关键选择 → 证据 → 学到什么 → 下一方向 | + +## 证据与真实性 + +- 成果优先写可核结果及其口径,不能量化时写可观察变化、交付物或他人采用情况,不编造数字。明确“负责、协作、支持、批准”等角色差异。 +- 时间线、组织名、客户名、作品链接和推荐语在公开前确认准确与授权;需匿名时保留问题、本人动作和结果的判断价值,不留下可反推的敏感细节。 +- 技能由近期作品、职责范围或实际使用场景支撑;自我定位可以有主张,但不能使用未获认可的资质、奖项或身份。 + +## 结构与高质量写法 + +先筛选与目标读者最相关的经历,不把完整人生经历当作专业证明。经历条目以动作和影响开头,背景只写理解贡献所需的约束;案例说明权衡和本人判断,比工具清单更能证明能力。CTA 具体到希望发生的下一步,并只提供获授权的联系方式。 diff --git a/skills/lark-doc/references/genres/route-platform.md b/skills/lark-doc/references/genres/route-platform.md new file mode 100644 index 0000000000..8c28bdd073 --- /dev/null +++ b/skills/lark-doc/references/genres/route-platform.md @@ -0,0 +1,9 @@ +# 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) | diff --git a/skills/lark-doc/references/genres/route-report.md b/skills/lark-doc/references/genres/route-report.md new file mode 100644 index 0000000000..3e88d40b04 --- /dev/null +++ b/skills/lark-doc/references/genres/route-report.md @@ -0,0 +1,10 @@ +# 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) | diff --git a/skills/lark-doc/references/genres/route-workplace.md b/skills/lark-doc/references/genres/route-workplace.md new file mode 100644 index 0000000000..ae17c76e8b --- /dev/null +++ b/skills/lark-doc/references/genres/route-workplace.md @@ -0,0 +1,17 @@ +# 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) | diff --git a/skills/lark-doc/references/genres/sop-tutorial.md b/skills/lark-doc/references/genres/sop-tutorial.md new file mode 100644 index 0000000000..65b3a5fa3f --- /dev/null +++ b/skills/lark-doc/references/genres/sop-tutorial.md @@ -0,0 +1,41 @@ +# Genre Contract: SOP / Runbook (`workplace.sop_tutorial`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 命令式、具体、顺序稳定,一步一动作并紧邻可观察判据,不写无条件的“适当 / 必要时” | +| 内容逻辑 | 先定 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,不得创建可执行稿。 + +## 步骤、异常与恢复 + +- 每个关键步骤只写一个动作,紧邻可观察结果、阈值与证据;验证需要操作时另列一步。未知偏差停止于已知安全状态,记录证据并升级。 +- high-risk 在不可逆动作前设置 hold point:列 go / no-go 信号、决策人和信号缺失时的安全终态。rollback 写触发条件、适用范围、步骤、阈值、停止点和恢复后业务验证,不能只写命令回执。 +- 有状态迁移另列不可逆点、写入归属、checkpoint / 幂等,以及完整、无重复、有序或等价验证;关闭 fallback 前必须证明新终态稳定。 + +## 高质量写法 + +让具备规定基础资格但不熟流程的人可独立复现;选择条件写在动作前,稳定原理链接出去,不混入原理课或临场诊断。按风险裁剪篇幅但不删证明义务;按适用治理要求由代表性执行者试跑,未经任何实际验证不得发布。 diff --git a/skills/lark-doc/references/genres/technical-doc.md b/skills/lark-doc/references/genres/technical-doc.md new file mode 100644 index 0000000000..82246cb4a1 --- /dev/null +++ b/skills/lark-doc/references/genres/technical-doc.md @@ -0,0 +1,39 @@ +# Genre Contract: Technical Document / 技术文档 (`workplace.technical_doc`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 精确、可证伪、术语与版本稳定;规范词仅用于明确采用的互操作、安全或验收语义 | +| 视觉约束 | 在有明确内容作用时用代码、表格、架构 / 状态 / 时序图等组件降低实现与诊断成本,但不得替代契约、证据或操作说明 | +| 内容逻辑 | 必须且只能选 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`;涉及安全 / 法务时先保全证据和升级。 diff --git a/skills/lark-doc/references/genres/wechat.md b/skills/lark-doc/references/genres/wechat.md new file mode 100644 index 0000000000..618b16f124 --- /dev/null +++ b/skills/lark-doc/references/genres/wechat.md @@ -0,0 +1,39 @@ +# Genre Contract: WeChat Official Account / 微信公众号文章 (`platform.wechat`) + +## 核心定位(硬约束) + +- 交付物是飞书文档中的“微信公众号风格”内容稿,不代表实际发布,也不执行微信平台审核、流量、商业或发布规则。 +- 视觉策略默认使用 `rich`,主动寻找图文结合的表达机会,但每个组件必须服务主线。 +- 写作风格可信、有观点、有叙事或论证推进,在专业感与亲近感之间保持平衡。公众号不是加长版小红书,也不是公文或报告换皮。 +- 一篇只服务一个读者任务和一个可兑现承诺;标题、封面、摘要、导语、正文与结尾围绕同一主线。不编造亲历、身份、数据、引语、案例或效果;无来源时不用“多数、普遍、研究表明”等统计口吻,材料不足时明确收窄表达。 +- 飞书源稿禁止使用 `callout`;生成后通过 Draft Profile Check 的 `profile.blocks` 检查,其他 block 按真实信息关系选择。 + +## 适用与消歧 + +用户明确要“微信公众号文章、公众号推文、微信长文、微信爆文、公众号风格”时使用,内容保存在哪里不影响本合同生效。 + +普通微信聊天消息、群公告、朋友圈文案、视频号口播、小程序页面和服务通知不走本合同。仅把微信作为研究对象、信息来源或业务渠道时也不触发;若同时要公众号稿和正式体裁,分别生成,不混写。 + +## 内容模式 + +| 模式 | 内容脊柱 | +|-|-| +| 知识 / 方法 | 读者处境 → 核心原理 / 结论 → 方法与验证 → 成本、例外和适用边界 → 可执行认识 | +| 观点 / 解释 | 现象或争点 → 中心判断 → 理由、证据与机制 → 相关反论 / 边界 → 校准后的结论 | +| 资讯 / 热点 | 已确认事实 → 为什么重要 → 必要背景与多方信息 → 争议 / 未知 → 当前结论或更新点 | +| 案例 / 故事 | 具体场景 → 选择与行动 → 可观察结果 → 代价 / 失误 → 可迁移洞见 | +| 品牌 / 行动 | 读者场景 → 有边界的价值 → 证据 / 体验 → 条件与取舍 → 清楚结论 | + +## 成稿要求 + +- 先钉住具体读者、核心问题与中心判断;内部比较信息清晰型、问题 / 冲突型、观点浓缩型标题,成稿只输出既有张力又不透支正文的一个。 +- 标题负责建立准确预期;摘要按需补充关键背景、判断或阅读收益,不复述标题。摘要、导语和首节必须各有信息增量。封面只保留一个视觉中心,图片文案不制造第二个主题。 +- 导语在首屏内用具体场景、问题、变化或判断说明“为什么值得读”,随后尽快进入主线,不用宏大背景、客套话或悬念拖延核心信息。 +- 正文沿一条逻辑线展开,小标题概括本节增量。段落各有一个主要意思,但长短随内容变化:重点句可独立成段,证据、故事和推理要保留完整上下文,避免短句过多造成逻辑断裂。 +- 使用自然、可交流的书面语;用具体细节、例子、转折和取舍形成作者声音,不靠网络热词、排比口号或统一句式制造“爆文感”,避免连续复用同一反转句式。 +- 完整稿至少给出一个封面或正文视觉方案;已有图片时就近用于提供证据、解释信息、建立场景或调节长文节奏。图片不设固定数量,也不为“图文并茂”强塞装饰图,正文仍须独立可读。 +- 结尾回扣开头问题或中心判断,留下结论、影响或自然的下一步;互动句、emoji 和话题标签均按需使用,不要求固定收尾动作。 + +## 交付前检查 + +确认标题没有透支正文,摘要与导语没有重复,文章主线连续,每节都在推进事实、故事、论证或方法,手机上容易扫读但不过度碎片化,图片确实帮助理解,且没有空洞口号、标题党、模板腔或虚构事实。 diff --git a/skills/lark-doc/references/genres/weekly-report.md b/skills/lark-doc/references/genres/weekly-report.md new file mode 100644 index 0000000000..6b453baff9 --- /dev/null +++ b/skills/lark-doc/references/genres/weekly-report.md @@ -0,0 +1,24 @@ +# Genre Contract: Weekly / Status Report (`workplace.weekly_report`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 具体、短、面向判断,稳定使用最小字段与状态语义,不用“持续推进”代替产出 | +| 内容逻辑 | 围绕报告对象和周期,按“总体状态 / 最大变化 → 对照基线的产出 → 偏差 / 风险 / 依赖 → 下一里程碑 → ask”推进,只写影响判断的变化 | +| 事实 / 边界 | 状态须回链范围、时间、质量、成本、资源或阻塞证据;事实、当前状态和下期计划分开;无基线或数据不足时写 unknown,不猜完成率、原因、owner 或日期 | +| 错误 | 活动流水账、无周期 / 基线、健康色无判据、风险被埋、猜测根因冒充事实、下一步无里程碑、ask 不可执行或自动汇总不可追,任一出现即失败 | + +## 适用与消歧 + +用于按固定或约定周期判断当前相对目标 / 计划 / 承诺的位置。解释已结束周期为何如此并改变下一轮走 `retrospective.md`;完整指标洞察走数据报告;一次性高层知会走 `memo-brief.md`。“报告 / 进展”单词本身不触发本体裁。 + +## 状态与证据 + +- 标明报告对象、周期 / 截至时间;进展使用已验收产出、里程碑或有口径指标,会议数、沟通和投入时长本身不等于进展。 +- On track / 红黄绿等状态须有预先定义或就近说明的判据。无基线时明确“无法判断是否按计划”,而不是默认绿色。 +- 风险、问题、依赖和阻塞按已知程度写影响、当前缓解、责任方与升级需求;冲突数据并列保留并标`[口径待核]`。 + +## 结构与高质量写法 + +个人短更新可收缩,项目群 / 月报可按需增加趋势、成本或预算,但不复制无用栏目。优先写相对上期和相对承诺的 delta;稳定低风险项可链接原记录。ask 写明对象、事项和需要时间,关键数据延迟时说明最近可用时间点及其判断影响。 diff --git a/skills/lark-doc/references/genres/white-paper.md b/skills/lark-doc/references/genres/white-paper.md new file mode 100644 index 0000000000..85336336b6 --- /dev/null +++ b/skills/lark-doc/references/genres/white-paper.md @@ -0,0 +1,32 @@ +# Genre Contract: White Paper / 白皮书 (`report.white_paper`) + +## 体裁规则表(硬约束) + +| 规则项 | 规则 | +|-|-| +| 写作风格 | 系统、清楚、克制;权威来自真实主体、证据与归属,不来自篇幅、正式腔或视觉复杂度 | +| 内容逻辑 | 先确认白皮书类型、发布主体、专业读者和期望判断,再用证据建立问题、评价标准或框架、论证、反例及应用边界;框架必须实际解释或比较 | +| 事实 / 边界 | 客观主张连接真实来源、时点、范围和限制;事实、解释、价值判断、提议与品牌立场可区分;政策身份、发布状态、利益、资助和案例选择不得虚构或隐匿;证据图与材料须确认使用权、来源和说明,复杂视觉附文字等价信息 | +| 错误 | 政策与品牌身份混写;标题或版式伪造权威;宏大背景填篇幅;自创框架仅作装饰;来源不可追;单一案例冒充共识;忽略反证 / 利益冲突;CTA 吞没证据;复杂组件代替论证 | + +## 适用与消歧 + +让专业读者系统理解并评估问题、框架或解决路径。先区分有权主体的政府政策白皮书与专业、技术或品牌资助白皮书;`白皮书`、`正式`、`权威`单独不产生政府或标准身份。明确研究问题和方法为核心走 [`research-report.md`](research-report.md),特定组织选项决策走 [`business-analysis.md`](business-analysis.md),设计 / RFC / 接口契约走 Technical,产品卖点、获客或 CTA 为主走 Marketing。 + +## 子类型 + +- **政府政策白皮书**:只有真实有权主体可使用;准确标政策、咨询、立法与发布状态,不模拟批准或法律效力。 +- **政策 / 专业问题白皮书**:围绕问题、证据、评价标准、方案和影响形成可审查论证。 +- **技术 / 行业 landscape 白皮书**:解释技术、标准或系统框架;一旦主要任务是批准实现设计或查询精确契约,改走 Technical。 +- **品牌资助白皮书**:证据评估仍须是主体任务;披露资助、产品利益和案例选择,转化内容与论证分层。 + +## 证据与边界 + +- 开头明确作者 / 发布主体、读者、使用场景、范围、文档状态、核心立场和期望判断。 +- 主张强度匹配证据层级;有限测试、相关观察、厂商数据或单一案例不得扩写成绝对承诺或行业共识。 +- 框架的每一层都须增加解释、比较或选择价值;问题原因、评价标准与方案逻辑相连,并处理重要反证、替代解释和可行性限制。 +- 主体或授权不明时用 `[发布主体待确认]`,不得写成政府、官方或标准;核心证据不足时收窄为 concept note / outline。利益关系或关键政策状态无法确认的发布稿标记 `blocked`。 + +## 结构与高质量写法 + +独立摘要(主体、论点、证据边界) → 问题与现有证据 → 评价标准或核心框架 → 逐层论证、方案与反例 → 应用 / 政策含义及条件 → 限制、利益关系与来源。摘要让忙碌读者复述主张和保留条件;长篇才增加目录或附录,不以背景、封面、缩写或组件制造权威感。 diff --git a/skills/lark-doc/references/genres/xiaohongshu.md b/skills/lark-doc/references/genres/xiaohongshu.md new file mode 100644 index 0000000000..584ec6298b --- /dev/null +++ b/skills/lark-doc/references/genres/xiaohongshu.md @@ -0,0 +1,38 @@ +# Genre Contract: Xiaohongshu Note / 小红书笔记 (`platform.xiaohongshu`) + +## 核心定位(硬约束) + +- 交付物是飞书文档中的“小红书风格”内容稿,不代表实际发布,也不执行小红书平台审核、禁词、流量或商业规则。 +- 视觉策略默认使用 `rich`,偏爱图文并茂和清晰轻松的阅读体验,但装饰不能代替内容。 +- 写作风格鲜活、有节奏、有画面感,可使用符合语境的 emoji。 +- 一篇只解决一个主要问题;标题、封面、首屏和正文围绕同一获得感并真正兑现。不编造亲历、身份、数字、效果或用户反馈,材料不足时用第二人称、场景化讲解或中性叙述。 +- 飞书源稿禁止使用 `callout`;生成后通过 Draft Profile Check 的 `profile.blocks` 检查,其他 block 按真实信息关系选择。 + +## 适用与消歧 + +用户明确要“小红书笔记、小红书写法、小红书 style、红书感、XHS 风格”时使用,内容保存在哪里不影响本合同生效。 + +仅把小红书作为研究对象、数据源或业务渠道时不触发:小红书运营方案走 Workplace,平台数据或竞品分析走 Report,规则说明走 Knowledge。若同时要小红书风格稿和正式体裁,分别生成,不混写。 + +## 笔记主任务 + +| 主任务 | 内容脊柱 | +|-|-| +| 教程 / 攻略 / 知识 | 痛点场景 → 核心判断 → 分步做法 → 易错点 / 限制 → 马上可做的一步 | +| 体验 / 测评 / 探店 | 使用场景 → 具体观察 → 亮点与槽点 → 适合谁 / 不适合谁 → 选择建议 | +| 观点 / 热点 | 争议或反差 → 核心判断 → 理由与例子 → 另一面 / 边界 → 留给读者的问题 | +| 个人经历 / 成长 | 真实困扰 → 转折瞬间 → 做过什么 → 可观察变化 → 可迁移认识 | +| 推荐 / 种草 / 活动 | 目标人群与场景 → 核心价值 → 具体理由 / 体验 → 使用条件与取舍 | + +## 成稿要求 + +- 先钉住具体读者、场景与获得感;内部比较搜索清晰型、痛点共鸣型、反差好奇型 3 个标题,成稿只输出正文能兑现的最强一个。 +- 首屏用 1—3 个短段落完成“具体场景 / 冲突 → 核心判断 → 内容预告”,不从宏大背景或自我介绍讲起。 +- 正文用短段落和有意义的小标题按信息增量推进;每节新增动作、观察、例子、判断或限制。“活人感”来自具体细节、选择和取舍,不靠强塞网感词。 +- emoji 可比正式体裁用得更积极,用于导航、语气和停顿,但不连续堆叠。围绕一个视觉中心设计封面,图片 / 截图 / 示意图就近服务对应内容;无可用图片时给出简短配图建议,正文仍须独立可读。 +- 核心主题词自然出现在标题或首屏,相关表达按需进入小标题和正文;话题标签少而相关,不为覆盖关键词而复读。 +- 结尾用一句记忆点收束;互动问题可选且至多一个,不要求固定收尾动作。 + +## 交付前检查 + +确认读者能一眼判断“这和我有关”,标题承诺已兑现,每节都有实质信息,手机上容易扫读,emoji 与图片确实帮助理解。出现公文腔、长铺垫、文字墙、题文错配、空情绪或虚构事实时返工。 diff --git a/skills/lark-doc/references/lark-doc-create-workflow.md b/skills/lark-doc/references/lark-doc-create-workflow.md new file mode 100644 index 0000000000..f66117b13c --- /dev/null +++ b/skills/lark-doc/references/lark-doc-create-workflow.md @@ -0,0 +1,121 @@ +# Lark Doc Authoring + +## Philosophy + +以下原则是每个内容、结构和视觉决策的判定依据;写作和复查时逐条套用,冲突时按「约束栈」排序。 + +- **读者本位**:落地前先回答:读者是谁、为什么要读、带着什么任务来。按读者的任务组织内容,不按功能或作者视角罗列。 +- **结构先行**:结论先行,先整体后局部;按逻辑分组与递进,依据关系选择列表、步骤或表格,使内容便于扫读。(特殊体裁除外) +- **视觉服从语义**:先确定全篇主线和每节的中心任务或命题,再让视觉层级复现内容优先级。文档脱离讲解仍须完整、连续、可独立阅读。 +- **最低理解成本**:选择最能降低读者理解、执行和出错成本的表达形式,而不是机械选择字符最少或制作成本最低的形式;删冗余,用短句、动词和数据,并按真实信息关系使用图、表格或交互组件。 +- **克制且连贯**:每个视觉元素必须承担导航、比较、解释、证据、行动,或体裁所需的氛围与品牌功能;相关文字与视觉相邻,同类关系复用同类组件和样式。去掉后不影响读者任务或预期语气的装饰应删除。 +- **约束栈**:事实 > 用户硬约束 > 读者任务 > 内容 > 组件样式;后项不得牺牲或放宽前项,格式与组件不得反向改变内容判断。 +- **表达一致**:同一对象、动作和状态全文同名;标题层级与编号采用统一体系,如下;用户提供样例时,在不违反更高优先级规则的前提下延续其有效结构、语气、术语和编号。 + - **自动编号模式**:每一个正文标题都写 `seq="auto"`,标题文本不手写任何前置序号。 + - **中文手写模式**:适用于公文或正式场景,在标题文本中手写 `一、→(一)→ 1.→(1)`;最忌中文层级配阿拉伯小数,绝不出现 `一、` 下接 `1.1`。 + +## Step Plan + +**CRITICAL:从零创作文档时按下述步骤依次执行,不可跳步。** + +### Step 1:理解读者任务、文档格式要求、硬约束和禁区。 + +### Step 2:选择 genre content contract。 + +下表文件均位于当前 Skill 的 `references/genres/` 目录。 + +- 路由表仅用于选择候选,不代替 contract。高置信命中后必须读取对应 Profile / Adapter,并按其中的路由与消歧规则复核;未读取不得确定该值或进入 Step 3。确认后记录固定短名,最多各读取一个;未命中时,`genre_contract` 和 `adapter` 均可使用 `"none"` 或 `null`。 +- 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:收集资料并扫描表达机会。 + +1. 强制扫描事实、数据、案例、引用和图片等资源缺口;内容需要而现有材料不足时必须检索或生成,判断需要图片且用户未提供素材时必须搜索图片。 +2. 根据用户要求、contract / adapter 限制和内容需要确定 `presentation_mode`,再识别真实信息关系并选择候选表达;不因命中关系就机械使用组件。 + + | 信息关系 | 候选表达 | + |-|-| + | 同组字段的精确比较或映射 | `table` | + | 流程、依赖、分支、时序、层级、因果、空间或拓扑关系 | `whiteboard` | + | 对象、场景、界面、外观、氛围、示例或视觉证据 | `img` | + | 复杂交互、动态状态、可探索数据或应用式布局 | `html5-block` | + | 两组简短、等权且适合横向阅读的信息 | `grid` | + | 单个关键提醒或限制 | `callout` | + | 简单并列、步骤或连续论述 | 列表或段落 | + +3. 按全篇、章节、block 三个尺度构图:相关内容相邻,同类关系保持相同顺序与对齐;正文可以是主表达,不要求每节都有 presentation block。 +4. 在写正文前确定计划使用的 block 和具体 `purpose`。Presentation Decision 的 `visual_plan.blocks` 只记录确需最低数量约束的 `whiteboard`、`img`、`html5-block`。三类均无硬性数量要求时写 `"blocks": []`。 + +`presentation_mode` 只表示模型采用的视觉策略;只有用户要求、contract / adapter 限制互相冲突时才询问用户: + +- `formal`:视觉正式、克制;不使用高亮块、emoji 或装饰性组件,只保留正式体裁确有必要的结构。 +- `normal`:按内容需要使用组件;只有能降低理解、执行或出错成本时才扩展视觉表达。 +- `rich`:主动利用图片、画板、HTML 和其他飞书组件;每个组件须有明确目的,不设全局数量配额。 + +### Step 4:提交 Presentation Decision,并初始化草稿。 + +生成完整 JSON;字段值必须来自 Step 1–3,不得照抄示例。`word_count` 仅在用户明确提出字数要求时加入,使用 `min` / `max`;单边无限制写 `null`,“约 N 字”按 ±10%,无要求时省略整个字段: + +```json +{ + "audience": "项目负责人", + "reader_task": "判断偏差并决定下一轮动作", + "genre_contract": null, + "adapter": null, + "presentation_mode": "rich", + "visual_plan": { + "reason": "需要用因果图解释偏差来源与后续行动依赖", + "blocks": [ + {"type": "whiteboard", "min_count": 1, "purpose": "展示偏差成因与行动依赖"} + ] + } +} +``` + +不预建临时目录、草稿或决策文件。将上述 JSON 原样替换命令中的占位符并实际执行: + +```bash +lark-cli docs +script --command init-draft --presentation-decision '<上方完整 JSON>' --format json +``` + +成功后: + +- 保持当前工作目录不变;将 `data.workspace` 原样记为 `work_dir`,将 `data.draft_path` 原样记为 `draft_path`;遵循 `data.tip`,后续始终使用 `@./`。 +- CLI 会创建独占的 `work_dir` 并保存 `.presentation-decision.json` 作为固定基线,**但不会创建 `draft_path` 指向的 XML**。`draft_path` 是当前任务可直接写入的新文件路径;要求、资料或 contract 实质变化时,提交新决策并重新初始化,不得直接改基线。 + +### Step 5:生成 release candidate。 + +读取 [`lark-doc-xml.md`](lark-doc-xml.md),并结合 Presentation Decision、适用 contract 和 Philosophy 生成完整 XML。使用扩展标签时按需读取 [`拓展标签`](lark-doc-xml-extended-blocks.md)。 + +1. 公开网络图片使用 ``;已有本地图片使用 ``;画板使用 `` 并遵循[`画板工作流`](lark-doc-whiteboard.md);HTML 使用 `` 并遵循[`拓展标签`](lark-doc-xml-extended-blocks.md)。 +2. 直接在 Step 4 返回的 `draft_path` 创建并写入完整 release candidate。 +3. 首次写入后,发现 XML 语法问题时只修复最小范围,不无故重写正确内容。 + +### Step 6:执行 Draft Profile Check。 + +1. 执行 `lark-cli docs +script --command parse --content "@./" --format json`。顶层 `ok` 仅表示命令执行成功,是否通过看 `data.assessment.status`。失败时按 `data.diagnostics[]` 局部修复;只有草稿为空、截断或结构无效时才全文重建。`parse` 不替代 XML 规则或服务端校验。 +2. Profile Check 通过后,按 [`lark-doc-xml.md`](lark-doc-xml.md) 复查标签、属性和值,并依据 Philosophy 检查事实与来源、用户硬约束、适用 contract / adapter 以及 `visual_plan`。最终 XML 能否写入以 `docs +create` 的服务端结果为准。 + +### Step 7:创建文档并处理局部失败。 + +1. 只有最新 release candidate 完成 Draft Profile Check 和 XML 规则复查后,才读取 [`lark-doc-create.md`](lark-doc-create.md),使用同一个 `draft_path` 创建文档。 +2. 创建结果存在 warning、局部资源失败或回查发现局部问题时,不得再次新建文档;读取 [`lark-doc-update.md`](lark-doc-update.md),对已创建文档做最小范围修复,并按 update 流程 fetch 验证。 + +### Step 8:清理并交付。 + +无论创建成功、失败或被阻塞,只要 Step 4 已返回 `work_dir`,就先离开该目录,再使用当前运行时的文件删除能力精确删除整个 `work_dir`;不要使用通配符,也不要删除目录外的用户原始文件。最终只交付用户需要的结果,并说明必要来源、未关闭缺口、异常、失败或阻塞原因,以及文档 URL 或 token。 diff --git a/skills/lark-doc/references/lark-doc-create.md b/skills/lark-doc/references/lark-doc-create.md index 461e76ec01..974020bea2 100644 --- a/skills/lark-doc/references/lark-doc-create.md +++ b/skills/lark-doc/references/lark-doc-create.md @@ -1,24 +1,15 @@ # docs +create(创建飞书云文档) -> **前置条件(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,只有 Authoring 明确判定为 Markdown 例外时才使用 Markdown。 -从 XML(默认)或 Markdown 内容创建一个新的飞书云文档。 - -> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,可承载更丰富的结构化内容)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。 +写入前必须按 `--doc-format` 读取对应格式参考:`xml` 读取 [`lark-doc-xml.md`](lark-doc-xml.md),`markdown` 读取 [`lark-doc-md.md`](lark-doc-md.md);Markdown 中使用 XML 扩展标签时还须读取 `lark-doc-xml.md`。 ## 命令 ```bash -# 创建 XML 文档(默认格式,推荐) -lark-cli docs +create --content '项目计划

目标

记录本周重点。

' - -# 仅当用户明确要求导入 Markdown 时才使用;文档标题用 --title,正文标题按内容自然组织 -lark-cli docs +create --doc-format markdown --title "项目计划" --content $'## 目标\n\n- 明确重点\n- 记录待办' +# 简单内容优先使用 `--content -`,文件导入如下: +lark-cli docs +create --doc-format xml --content "@" +lark-cli docs +create --doc-format markdown --content "@./draft.md" ``` ## 返回值 @@ -35,46 +26,29 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'# "new_blocks": [ { "block_id": "blkcnXXXX", "block_type": "whiteboard", "block_token": "boardXXXX" } ] - } + }, + "warnings": [], + "tips": "" } } ``` -- **`document.new_blocks`**:本次操作新增的 block 列表(如画板)。`block_id` 可用于 `docs +update` 的 `--block-id` 做精确编辑;`block_token` 是资源块(如画板)的 token,可交给 `lark-whiteboard` 等 skill 继续操作 - -> \[!IMPORTANT] -> 如果文档是**以应用身份(bot)创建**的,如 `lark-cli docs +create --as bot` 在文档创建成功后,CLI 会**尝试为当前 CLI 用户自动授予该文档的 `full_access`(可管理权限)**。 -> -> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果: -> - `status = granted`:当前 CLI 用户已获得该文档的可管理权限 -> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份(bot)授予当前用户权限 -> - `status = failed`:文档已创建成功,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该文档 -> -> `permission_grant.perm = full_access` 表示该资源已授予”可管理权限”。 -> -> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。 +- **`document.new_blocks`**:本次操作新增的 block 列表(如画板)。`block_id` 可用于 `docs +update` 的 `--block-id` 做精确编辑;`block_token` 是资源块(如画板)的 token,可交给 `lark-whiteboard` 等 skill 继续操作。 +- **`warnings`**:服务端返回的警告列表;`ok=true` 时也要检查,按提示确认是否存在降级或未完全处理的内容。 +- **`tips`**:服务端返回的后续处理建议;为空表示没有额外建议,非空本身不表示创建失败。 +- **`permission_grant`**:仅以 bot 身份创建时返回。CLI 会尝试为当前 CLI 用户授予新文档的 `full_access`;`status` 为 `granted` 表示授权成功,`skipped` 表示没有可用的当前用户 `open_id`,`failed` 表示文档已创建但授权失败。`perm` 固定为 `full_access`,失败或跳过时按 `message` / `hint` 处理。**自动授权不等于 owner 转移;用户要求转移 owner 时必须单独确认。** ## 参数 -| 参数 | 必填 | 说明 | -| ------------------- | -- |---------------------------------------------| -| `--title` | 否 | 文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `...`;多个标题仅保留第一个并在 `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 比例或固定图表 +|参数|必填|说明| +|-|-|-| +|`--title`|否|文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `...`;多个标题仅保留第一个| +|`--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`。不要混用完整的 XML 与 Markdown 文档格式;Markdown 中允许使用文档已定义的 XML 扩展标签。| +|`--parent-token`|否|父文件夹或知识库节点 token(与 `--parent-position` 互斥)| +|`--parent-position`|否|父节点位置,如 `my_library`(与 `--parent-token` 互斥)| -## 参考 +## 需要回查文档 -- [`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) — 插入图片/文件到文档 +用 `lark-cli docs +fetch --doc "" --detail with-ids` 回查,若需要更多信息可查看 [`+fetch`](lark-doc-fetch.md)。 diff --git a/skills/lark-doc/references/lark-doc-fetch.md b/skills/lark-doc/references/lark-doc-fetch.md index 04e1ad1515..359bc19973 100644 --- a/skills/lark-doc/references/lark-doc-fetch.md +++ b/skills/lark-doc/references/lark-doc-fetch.md @@ -1,81 +1,78 @@ +# docs +fetch(读取飞书云文档) -# docs +fetch(获取飞书云文档) +读取整篇文档,或按目录、章节、区间和关键词获取局部内容。 -## 命令 +## 常用示例 ```bash -# 获取文档(默认 XML,simple) -lark-cli docs +fetch --doc "https://xxx.feishu.cn/docx/Z1Fj...tnAc" +# 读取整篇文档 +lark-cli docs +fetch --doc "文档URL或token" -# Markdown 格式 -lark-cli docs +fetch --doc Z1Fj...tnAc --doc-format markdown +# 按 URL 中的 #share 锚点局部读取 +lark-cli docs +fetch --doc '文档URL#share-anchor' -# 带 block ID(用于后续 block 级更新) -lark-cli docs +fetch --doc Z1Fj...tnAc --detail with-ids +# 按关键词定位 +lark-cli docs +fetch --doc Z1Fj...tnAc --scope keyword --keyword "部署|发布|上线" -# 只拿目录 +# 先查看目录,再读取指定章节 lark-cli docs +fetch --doc Z1Fj...tnAc --scope outline --max-depth 3 - -# 按 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 "部署|发布|上线" +lark-cli docs +fetch --doc Z1Fj...tnAc --scope section --start-block-id blkTitle ``` -## 选 `--detail`(每块详细度) - -| 意图 | `--detail` | 说明 | -|------|-----------|------| -| **只读**:浏览或总结文档内容 | `simple`(默认) | 简洁 XML/Markdown,不含 block ID、样式属性、引用元数据 | -| **定位**:需要 block ID 与其他业务交互 | `with-ids` | 包含 block ID(如 `

`),可用于 `+update` 的 `--block-id`,也可用于拼接 `文档URL#block_id` 形式的直达链接 | -| **编辑**:任何修改文档内容的需求 | `full` | 包含 block ID + 样式属性 + 引用元数据,提供完整文档结构信息 | +## 参数 -## 选 `--scope`(读取范围) +|参数|必填|说明| +|-|-|-| +|`--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`| -`--scope` 和 `--detail` 正交可组合。**省略 `--scope` 即读整篇;获取一小节时优先用局部读取。** +## 选择详细度:`--detail` -| 模式 | 何时用 | 关键参数 | 行为要点 | -|-|-|-|-| -| `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) | 每处命中按"最小包容单元"输出;**自动去重**(同容器多命中 → 单个容器,同表格多行命中 → 合并切片) | +|目的|取值|返回内容| +|-|-|-| +|浏览、总结|`simple`(默认)|简洁 XML/Markdown,不含 block ID、样式和引用元数据| +|定位、跳转|`with-ids`|包含 block ID,可用于 `+update --block-id`,也可拼成 `文档URL#block_id` 直达链接| +|编辑文档|`full`|包含 block ID、样式和引用元数据,保留完整结构信息| -> 💡 **多关键词用 `\|` 拼接(OR 语义,任一命中即返回)**:例 `"部署\|发布\|上线"`,三词任一命中都进结果,适合**同义词/别名/多业务术语**一次召回(如 `bug\|缺陷\|故障`)。 +需要修改文档时使用 `full`;只读场景通常不必获取额外元数据。 -**设置 `--scope` 时共用** `--context-before` / `--context-after` / `--max-depth`。 +## 选择读取范围:`--scope` -- `--max-depth`:`outline` = 标题层级上限(3 = h1~h3);其它模式 = 被选块的子树遍历深度(`-1` 不限,`0` 仅块自身)。 -- `--context-before/--context-after`:**只对整块顶层单元生效**;命中落在容器/表格内(返回容器或切片)时 before/after 被忽略,需要更大范围改用 `section` / `range` 显式指定。 +`--scope` 与 `--detail` 可以组合。优先读取满足任务所需的最小范围;只有确需全文时才省略 `--scope`。 -**决策顺序**(核心原则:**局部获取优于全量获取**,根据需求形态选起点,必要时多步组合收敛范围): -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`;不要为省事直接读整篇 +|模式|适用场景|关键参数|返回行为| +|-|-|-|-| +|`outline`|结构未知,先查看目录|`--max-depth`|扁平列出标题;返回的标题 ID 可作为 `section` 或 `range` 的端点| +|`section`|读取某个标题对应的整节|`--start-block-id`(必填)|顶层标题展开到下一个同级或更高级标题之前;容器内节点(含内嵌标题)按最小包容单元返回容器或表格切片| +|`range`|已知精确起止位置|`--start-block-id`、`--end-block-id` 至少一个|同一顶层序列按区间切片;同一容器返回整个容器;同一表格返回瘦身切片;跨顶层时完整返回端点所在的顶层块| +|`keyword`|只有关键词或模糊线索|`--keyword`(必填)|按最小包容单元返回命中;同一容器的多处命中自动去重,同一表格的多行命中合并为切片| -## 局部读取的输出结构:`` 与 `` +`keyword` 会依次尝试子串、归一化、分词形变和 RE2 正则匹配。多关键词使用 `|` 表示 OR,例如 `部署|发布|上线`;任一分支命中即返回。 -设置 `--scope` 时返回的 `content` 被一个 `` 节点包裹,属性包含 `mode` / `requested-start` / `requested-end` / `keyword`(按需)。子节点只有两种形态: +范围参数的共同规则: -- **顶层块**:完整块直接作为 `` 的子节点,无额外包裹。 -- **``**:非顶层节选(容器整体 / 表格瘦身切片)。 - - `top-block-id`:所在顶层块 id,想看该块全貌时作 `section` / `range` 锚点再拉一次。 - - `parent-block-path`:从顶层块到 excerpt 内容直接父节点的 id 路径,`/` 分隔(表格切片时即表格自身 id)。 +- `--max-depth`:`outline` 中 `3` 表示列出 h1~h3;其它模式中 `0` 表示仅返回块自身,`-1` 表示不限深度。 +- `--context-before` / `--context-after`:仅对完整的顶层块生效。命中位于容器或表格内时会被忽略;如需更大范围,改用 `section` 或 `range`。 -**看到 `` 即意味着这是节选**,不能假设看到了该顶层块的全貌。 +推荐选择顺序: -**表格默认瘦身**:即便 `` 本身是顶层块也只返回 thead + 命中 tr。想拿整张表 → `range --start-block-id --end-block-id `;切片范围恰好覆盖全部 tr 时 SDK 自动升级为整块、不包 ``。 +|已知信息|首选方式|后续动作| +|-|-|-| +|具体术语、错误码或标识|`keyword`|上下文不足时,用返回的 `top-block-id` 再执行 `section` 或 `range`| +|章节或标题|`outline --max-depth 3`|获取标题 ID 后执行 `section`| +|精确起止位置|`range`|按需调整端点或深度| +|没有关键词,也不了解结构|`outline`|根据目录转入 `section` 或 `range`| +|确实需要整篇|省略 `--scope`|—| ## 返回值 @@ -85,7 +82,7 @@ lark-cli docs +fetch --doc Z1Fj...tnAc \ "identity": "user", "data": { "document": { - "document_id": "doxcnXXXX", + "document_id": "docToken", "revision_id": 12, "content": "标题

文档内容...

", "reference_map": { @@ -100,49 +97,35 @@ lark-cli docs +fetch --doc Z1Fj...tnAc \ } } ``` - `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` 时会被 `` 包裹,详见上文"局部读取的输出结构"。 +### 理解局部读取结果 + ## 参数 -| 参数 | 必填 | 说明 | -|------|------|------| -| `--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` | - -## 图片、文件、画板的处理 - -**文档中的素材以 XML 标签形式出现:** - -```xml - - - -``` +设置 `--scope` 后,`content` 外层是 ``,并按需携带 `mode`、`requested-start`、`requested-end` 或 `keyword` 属性。其子节点有两种形式: + +- **顶层块**:直接作为 `` 的子节点,表示返回了完整块。 +- **``**:表示只返回了容器或表格中的节选。 + - `top-block-id` 是节选所在的顶层块 ID。需要查看完整块时,可将它作为 `section` 或 `range` 的锚点重新读取。 + - `parent-block-path` 是从顶层块到节选内容直接父节点的 ID 路径,以 `/` 分隔;表格切片中即表格自身 ID。 + +看到 `` 时,不要假设已经获取了整个顶层块。 -- `` / `` 带 `url` 时,直接用该 URL 下载即可(普通 HTTP GET),无需走 shortcut。 -- 没有 `url`、或只想预览 → `docs +media-preview --token --output ./preview_media` -- 明确下载,或目标是 ``(画板只能走 shortcut) → `docs +media-download --token --output ./downloaded_media` -- 文档封面图不是正文素材;下载/更新/删除封面图 → `docs +resource-download/+resource-update/+resource-delete --type cover` +表格默认瘦身:即使 `
` 本身是顶层块,也只返回表头和命中的行。读取整张表时,使用 `range --start-block-id --end-block-id `。如果切片覆盖全部数据行,SDK 会自动返回完整表格,不再包裹 ``。 -## 嵌入电子表格 / 多维表格 +## 处理文档内嵌资源 -返回中可能含 ``、``、``。内部数据无法通过 `docs +fetch` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。 +|返回内容|处理方式| +|-|-| +|``、``|有 `url` 时仅下载可信的公开 HTTPS URL:拒绝 userinfo 及解析到 private、loopback、link-local、multicast、unspecified 地址的 host,并逐次校验重定向;不满足时禁止请求。无 `url` 时提取 `token`,预览用 `docs +media-preview`,下载用 `docs +media-download`| +|``|提取 `token`,使用 `docs +media-download`| +|``、``|提取 `token` 和 `sheet-id`,转到 [`lark-sheets`](../../lark-sheets/SKILL.md)| +|``、``|提取 `token` 和 `table-id`,转到 [`lark-base`](../../lark-base/SKILL.md)| +|``|提取 `vc-node-id`,使用 [`lark-note`](../../lark-note/SKILL.md) 的 `note +detail`| +|``|提取 `src-token` 和 `src-block-id`,读取源文档并定位 block| ## 参考 -- [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-resource-cover](lark-doc-resource-cover.md) — 读取、更新、删除文档封面图 +- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材或画板缩略图 diff --git a/skills/lark-doc/references/lark-doc-md.md b/skills/lark-doc/references/lark-doc-md.md index b8ae2d0a32..c115144122 100644 --- a/skills/lark-doc/references/lark-doc-md.md +++ b/skills/lark-doc/references/lark-doc-md.md @@ -48,7 +48,7 @@ 自行构造 Markdown 内容写入时同理:如字面文本 `a]b` 应写为 `a\]b`,`C:\Users` 应写为 `C:\\Users`。 ## Shell 传参 -- **首选文件传参**:`--content` 支持 `@path/to/file.md`(读文件)和 `-`(读 stdin),彻底绕开 shell 转义;多行、含特殊字符、长文本强烈推荐。字面量以 `@` 开头时用 `@@` 转义(`--pattern` 不支持 `@file`) +- **首选文件传参**:`--content` 支持 `@./path/to/file.md`(读文件)和 `-`(读 stdin),彻底绕开 shell 转义;多行、含特殊字符、长文本强烈推荐。字面量以 `@` 开头时用 `@@` 转义(`--pattern` 不支持 `@file`) - **⚠️ `@file` 路径限制**:`@file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下(如 `./_content.md`),用完自行清理。 - **默认用单引号 `'...'`**:完全字面量,`$`、`` ` ``、`\`、`>`、`\` 等全部原样保留 - **双引号 `"..."`**:会展开 `$变量`、反引号和 `$(...)` 命令替换,`\` 仍参与转义,易踩坑 @@ -66,6 +66,10 @@ Markdown 格式支持通过 URL 插入网络图片,图片将自动从 HTTP 下 - URL 支持 `http://` 和 `https://` 协议 - 对应的 XML 格式为:`` +本地图片使用 `![alt](@./images/photo.png)`(路径含空格时写作 `![alt](<@./images/product shot.png>)`);路径必须位于当前工作目录内,`alt` 会作为 caption。附件使用 `` + +目前不支持将 Base64 Data URI(如 `data:image/png;base64,...`)直接作为 Markdown 图片地址传入;如仅有 Base64 数据,请先解码为本地图片文件,再使用上述 `@./...` 路径上传。 + ## Markdown 不支持的 Block 类型 非原生 Markdown 语法的内容(如下划线、高亮框(Callout)、勾选框、多维表格、画板、思维导图、电子表格、网格布局、引用(@文档/@人)、按钮、日期提醒、行内文件、文字颜色/背景色、同步块等)采用 XML 语法表示,详见 [`lark-doc-xml.md`](lark-doc-xml.md)。 diff --git a/skills/lark-doc/references/lark-doc-script.md b/skills/lark-doc/references/lark-doc-script.md new file mode 100644 index 0000000000..6be7f1761d --- /dev/null +++ b/skills/lark-doc/references/lark-doc-script.md @@ -0,0 +1,76 @@ +# `docs +script` + +## 脚本列表 + +| `--command` | 用途 | +|-|-| +| `init-draft` | 创建带 Presentation Decision 基线的独占工作区,并预留尚不存在的 XML 路径。 | +| `parse` | 解析本地或在线文档,返回画像并检查决策与资源。 | + +每个脚本只使用其小节列出的专用参数;所有脚本均可使用文末的通用参数。 + +## `init-draft` + +### 参数 + +| 参数 | 必填 | 用法 | +|-|-|-| +| `--command init-draft` | 是 | 选择本脚本。 | +| `--presentation-decision` | 是 | 完整决策 JSON;接受内联 JSON、`@./decision.json` 形式的 CWD 下相对路径或 `-`(stdin)。 | + +```bash +lark-cli docs +script --command init-draft \ + --presentation-decision '<完整 Presentation Decision JSON>' \ + --format json +``` + +`data` 的结构如下;实际随机段为 8 位十六进制字符: + +```json +{ + "workspace": "draft_a1b2c3d4_folder", + "draft_path": "draft_a1b2c3d4_folder/draft.xml", + "tip": "The workspace directory has been created successfully. draft_path points to a new XML file that does not exist yet. Create and write the file directly without reading it first." +} +``` + +- 在生成正文前执行;不要自行创建工作目录或决策文件。CLI 固定生成 `draft_<8位十六进制字符>_folder/draft.xml`,以返回的实际路径为准。 +- 决策必须是单个 JSON 对象,包含 `audience`、`reader_task`、`genre_contract`、`adapter`、`presentation_mode` 和 `visual_plan`。`presentation_mode` 取 `formal|normal|rich`;`genre_contract`、`adapter` 使用固定短名、`"none"` 或 `null`。 +- `visual_plan` 包含非空 `reason` 和 `blocks` 数组;每项为 `{type,min_count,purpose}`,`type` 不重复,`min_count` 为正整数。按本 Skill 创建文档时,`blocks` 只对 `whiteboard`、`img`、`html5-block` 设置最低数量,其他表达按内容需要使用但不设数量约束;三类均无需约束时写 `[]`。CLI 为外部决策兼容 `type: "list"`,检查时将 `
`、``、` - - - -引文标题 - - - - - -``` +- 文本转义:`<` → `<`,`>` → `>`,`&` → `&`,换行符 `\n` → `
`。 +- 错误:`<p>内容</p>` +- 正确:`

A & B 的对比:1 < 2

` diff --git a/skills/lark-doc/references/style/lark-doc-create-workflow.md b/skills/lark-doc/references/style/lark-doc-create-workflow.md deleted file mode 100644 index a9ab5c3b15..0000000000 --- a/skills/lark-doc/references/style/lark-doc-create-workflow.md +++ /dev/null @@ -1,47 +0,0 @@ -# 从零创作工作流 - -用户提供主题、需求或简要说明,需要生成一份新的飞书文档时,遵循本工作流。 - -## 核心方法论 — Code-Act Loop - -通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行: - -1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么 -2. **Execute(执行)** — 由主 Agent 自己运行 `lark-cli docs` 命令推进正文;仅画板渲染按需隔离到 SubAgent(见步骤三) -3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标 -4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环 - -循环在文档达到质量标准且满足用户需求时结束。不要试图一次性产出完美内容——迭代打磨效果更好。根据用户实际需求灵活决定文档结构和版块,而不是套用固定模板。 - - -## 典型 Code-Act Loop 流程 - -### 步骤一:规划与撰写(单 Agent 串行) - -正文由主 Agent 串行维护,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效。 - -1. 分析用户需求:受众、目的、范围 -2. 设计大纲:根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式;不要默认套固定章节、固定开头或固定富 block 配比 -3. `docs +create` 创建并撰写: - - **短文档**:一次写入完整内容。使用 Markdown 时,避免同时传入 `--title` 和同名 `# 标题` - - **长文档**:先建骨架(标题 + 各级标题),再由主 Agent **顺序逐节**用 `block_insert_after --block-id <章节标题 block_id>` 补全正文;写完一节再写下一节,始终带着已写内容的上下文,保证衔接、不重复 - - ⚠️ 不要一次性把超长完整内容塞进 `--content`,容易触发字符/参数限制;长文按节分次写入 - - ⚠️ 同一节内多次插入时,要锚到**上一个新插入的 block**(按 [`lark-doc-update.md`](../lark-doc-update.md) 的「Block ID 生命周期」),否则反复锚同一个标题会让段落顺序颠倒 - - ⚠️ 若先建骨架写了占位摘要,补正文时**删除占位摘要**,不要留残渣 - - ⚠️ **`@file` 路径限制**:`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理 - -### 步骤二:整合审查与画板识别(串行) - -4. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果 -5. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材;检查跨节有无重复、矛盾或断流。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题就地定向修正 -6. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容 - -### 步骤三:画板处理与润色 - -7. **优先处理步骤二识别出的画板需求**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent -8. 由**主 Agent 自行润色**(不另起内容子 Agent,正文始终一人维护):文字密集且不易读时,优先拆段、加小标题或调整顺序——叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`);只有确实存在行列数据时才用 ``。其余富 block 的取舍一律遵循 `lark-doc-style.md` 的写作原则,不主动堆叠。需要明显分隔的主题可补充 `
`,不强制章节间都使用。本地图片使用 `docs +media-insert` 插入 - -### 步骤四:专项校验 - -9. **字数门禁**:如果用户给出任何明确字数要求(如“700-800 字”“1000 字左右”“不少于 500 字”“控制在 800 字以内”),本步骤必须执行,不属于按需项。读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;未得到脚本统计结果前,不得向用户声明“符合字数要求”。若没有明确字数要求,则跳过本项,不读取该 workflow。若执行了专项校验,向用户呈现目标区间、`word_count` 和达标结论 -10. **重复标题检查**:文档生成后,检查文档标题和正文第一个标题块是否重复;若重复,删除或改写正文第一个标题块,避免读者看到同一标题连续出现 diff --git a/skills/lark-doc/references/style/lark-doc-style.md b/skills/lark-doc/references/style/lark-doc-style.md deleted file mode 100644 index 71ba1bd974..0000000000 --- a/skills/lark-doc/references/style/lark-doc-style.md +++ /dev/null @@ -1,68 +0,0 @@ -# 飞书文档写作原则 - -写飞书文档,像一个该领域资深的人类作者那样写,而不是把内容"装配"成组件。 -本文只讲"何时用、什么风格";具体标签 / 命令语法见 [`lark-doc-xml.md`](../lark-doc-xml.md)。 - -## 一、用户明确要求优先 - -用户点名要某种格式——高亮块、分栏、列表、某编号体例、表格、画板、某模板、某已有文档的风格——**一律照用户的来,下面的"默认克制"全部让位**。用户给了样例或已有文档,就沿用它的结构与语气。 - -## 二、默认写连贯段落 - -用户没指定时,**默认是连贯段落**;其余按内容类型分流,别一律"少用结构",也别什么都升标题: - -| 内容 | 用什么 | ❌ 别 | -|---|---|---| -| 叙述、论证、分析、说明 | **连贯段落** | 拆成列举 | -| 真·行列数据(预算、指标、对比、排期、字段说明) | **表格** | 写成段落或把字段堆成一行 | -| 字段:值(主题、时长、负责人等,少量) | **加粗标签行**或一句话 | 每字段一个标题 | -| 方法 / 措施 + 每项一段描述 | **加粗引导句段落**(「**全程督导。**…」) | 每项升标题 | -| 任务清单 / 检查项 / 待办事项 | **``** | 用普通列表替代可交互待办 | -| 纯短并列项(无描述,如材料清单) | 列表 | — | -| 章节(内容成块、需在目录导航) | 标题层级 | — | - -- 判断标准:**去掉结构后能顺成段落,就用段落;成行成列的数据,就用表格。** -- **红线一:标题层级只给"章节"。** "小标题 + 一两句话"的小项(字段、方法、要点)不该占标题层级——按上表降成标签行 / 加粗引导句段落(否则目录里全是没信息量的条目)。 -- **红线二:列举(「一是 / 二是」「第一 / 第二」「(1)(2)(3)」)只给真正并列的具体项,且别每节都用。** - - 「一是 / 二是」是党务列举的措辞——只用在列具体的**问题 / 措施**那一处;背景、现状、认识、分析、过渡、总结**一律成段**。 - - **整篇每段 / 每节都"一是 / 二是",和"每段一个 bullet"是同一个骨架化的错——不因为是党务就变对**(纯清单 / 台账类除外)。 - -## 三、按体裁写 - -- **公文 / 法律 / 学术 / 申报 / 项目方案等严肃正式提交物**:靠规范的标题层级、段落与编号体系表达;**默认不用高亮块、分栏**,要强调用加粗或规范小标题。 -- **面向公众号、微信等外部平台粘贴 / 发布的内容**:不用飞书特有富 block(高亮块、分栏等),粘出去会丢样式 / 错乱;改用标准标题、段落、列表、引用。 -- **一般文档**:以可读为先,不堆砌结构。 - -## 四、编号与层级 - -- **一套编号体例、全篇一致;最忌中文大层级与阿拉伯小数编号混用。** - - 公文 / 正式材料常用:「一、→(一)→ 1.→(1)」(中文大层级 + 阿拉伯细分层级)。 - - 学术 / 技术 / 商业报告:「1 → 1.1 → 1.1.1」或「一、→(一)→ 1.」,**择一**。 - - ⚠️ **「一、」只能配「(一)」;要用阿拉伯小数就从顶层全用「1 / 1.1」。绝不「一、」配「1.1 / 2.1」**——这是最常见的混用。 - - **不混用**多套(别"第X部分"+"一、"+"1."混着来);**同级不跳号**;**不跳级**。 -- **编号 / 标题层级只给"章节"**,不要为了凑齐体例把每个小项都编上「(一)」、升成标题(小项处理方式见上文「二、默认写连贯段落」)。 -- 简单的 1.2.3 并列项用原生 `
` 让飞书自动编号、自动对齐;「一、(一)」原生产不出,才手打成文字——此时用标题级别表达层次,**不靠手动缩进**、各级顶格(全角括号「()」叠手动缩进会视觉错位)。 - -## 五、飞书特有组件,克制使用 - -- **高亮块 ``**:很重的强提醒信号,**默认不用**;只给"不提醒就会出错 / 遗漏"的关键项,全文极少(0~1 个),不要每节导语 / 结论都做成高亮块。 -- **分栏 ``**:仅左右信息量相当、确需并排对照的短内容;否则用段落或表格。 -- **画板**:默认用文字,只在**图示明显比文字更易懂**(流程、架构、时间线、对比、占比等)或用户要求时才用。怎么插、用哪种类型见 [`lark-doc-xml.md`](../lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。 -- **颜色**:默认朴素、不上色;需要时保持语义一致,按下表选择对应颜色,不为装饰上色。可用色见 [`lark-doc-xml.md`](../lark-doc-xml.md) 的「美化系统」。 - -| 语义 | 背景色 | 文字色 | -|-|-|-| -| 信息、说明 | `light-blue` | `blue` | -| 成功、推荐 | `light-green` | `green` | -| 警告 / 错误 / 风险 | `light-red` | `red` | -| 注意、待确认 | `light-yellow` | `yellow` | -| 中性、辅助 | `light-gray` | — | - -## 六、写完自检 - -交付前快速回看: -- **叙述是否被列举化**:背景 / 现状 / 认识 / 分析 / 成效 / 过渡 / 总结等应成段;列举只用于同层级、可并列处理的信息,如问题、措施、步骤、任务或材料清单。若正文反复使用连续编号、项目符号或固定并列句式,导致内容缺少叙述,应把背景 / 认识 / 分析 / 过渡改写成有承接关系的段落(纯清单 / 台账类除外)。 -- **数据是否正确呈现**:成行成列的数据应使用表格呈现,不要写成段落,也不要用分隔符把多个字段硬串在一起。 -- **标题是否滥用**:"小标题 + 一句话"的小项不要升成标题;应改成标签行、加粗引导句段落或普通段落。 -- **编号是否统一**:全篇一套、不跳号、不跳级,尤其不要中文 + 阿拉伯混用(如「一、」配「1.1」)。 -- **组件是否克制且保真**:高亮块 / 分栏 / 画板 / 颜色应符合体裁和用户要求;引用 / 图片 / 资源块必须保留。 diff --git a/skills/lark-doc/references/style/lark-doc-update-workflow.md b/skills/lark-doc/references/style/lark-doc-update-workflow.md deleted file mode 100644 index 31ed974776..0000000000 --- a/skills/lark-doc/references/style/lark-doc-update-workflow.md +++ /dev/null @@ -1,48 +0,0 @@ -# 改写增强工作流 - -用户提供已有文档链接或 token,需要改写、润色、补充或重排版时,遵循本工作流。 - -## 核心方法论 — Code-Act Loop -通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行: -1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么 -2. **Execute(执行)** — 由主 Agent 自己运行 `lark-cli docs` 命令推进改写;仅画板渲染按需隔离到 SubAgent(见步骤二) -3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标 -4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环 - -## 核心原则:精准手术优于全量覆盖 -1. **精准手术**:只改用户指定的 block,不改其他 block。 -2. **全量覆盖**:如果用户明确要改整篇,才用 `overwrite` 命令。 -3. **保真约束**:改写时原文里的 ``(@人)、``(@文档)、``、``、``、``、``、`` 等行内组件和资源块一律原样保留(含所有 token / user-id / doc-id 属性),不许替换成纯文本姓名、链接或占位符。 - -## 工作流程 - -### 步骤一:分析与画板识别(串行) - -1. **选择读取范围**(节省上下文的关键): - - 用户只改某一节 / 文档较大 → 先 `docs +fetch --scope outline --max-depth 2` 拿目录,再 `docs +fetch --scope section --start-block-id <目标标题id> --detail with-ids` 精读该节(`section` 会自动展开到下一个同级/更高级标题前,不用手动算结束 block id) - - 需要精确跨节区间 → `docs +fetch --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾) - - 用户只给了模糊关键词 → `docs +fetch --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids` - - 用户明确要改整篇 → `docs +fetch --detail with-ids` - - 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) 中「选 `--scope`(读取范围)」小节 -2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解 -3. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段 -4. 向用户简要说明改进计划(包含识别出的画板机会) - -### 步骤二:定向改写(单 Agent 串行) - -5. **优先处理步骤一识别出的画板候选段落**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent -6. 由主 Agent **顺序逐节**改写,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效: - - 沿用或轻微调整已有文档风格,除非用户要求彻底重排版 - - 优先通过重写段落、调整标题、补充小标题提升可读性;叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`) - - 富 block 是可选表达手段,不因固定比例而添加,取舍遵循 `lark-doc-style.md` 的写作原则;画板类需求只走第 5 步 - -### 步骤三:验证(串行) - -7. 获取更新后文档局部内容,检查是否符合用户目标和已有风格 -8. 检查是否满足用户目标并保留原有关键内容。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题则定向修正 - -### 步骤四:专项校验(按需执行) - -9. 仅当用户预期需要校验字数时,才读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;否则跳过本项,不读取该 workflow。若执行了专项校验,向用户呈现结果 - -**上下文节省提示**:主 Agent 改某节时如需重新读取,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉当前章节,不要重复拉全文。 diff --git a/skills/lark-doc/scripts/doc_word_stat.py b/skills/lark-doc/scripts/doc_word_stat.py deleted file mode 100755 index 46fe1ff86a..0000000000 --- a/skills/lark-doc/scripts/doc_word_stat.py +++ /dev/null @@ -1,1243 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -"""Standalone Lark Docs word and character counter for XML or Markdown input.""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -import unicodedata -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Literal, Protocol -from xml.etree import ElementTree as ET - -# --------------------------------------------------------------------------- -# Data model -# --------------------------------------------------------------------------- - -@dataclass -class TextRun: - text: str - attrs: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class Block: - type: str - attrs: dict[str, Any] = field(default_factory=dict) - children: list["Block"] = field(default_factory=list) - text_runs: list[TextRun] = field(default_factory=list) - raw: Any = None - - -@dataclass -class Segment: - text: str - block_type: str - block_id: str | None = None - kind: str = "text" - boundary_before: bool = True - boundary_after: bool = True - - def to_dict(self) -> dict[str, Any]: - return { - "text": self.text, - "block_type": self.block_type, - "block_id": self.block_id, - "kind": self.kind, - "boundary_before": self.boundary_before, - "boundary_after": self.boundary_after, - } - - -@dataclass(frozen=True) -class UnknownBlock: - type: str - block_id: str | None = None - action: str = "recurse_children" - - def to_dict(self) -> dict[str, str | None]: - return { - "type": self.type, - "block_id": self.block_id, - "action": self.action, - } - -# --------------------------------------------------------------------------- -# Counting rules -# --------------------------------------------------------------------------- - -CHINESE_PUNCTUATION = set(",。!?;:、()《》〈〉“”‘’【】「」『』〔〕…—~·¥") -ENGLISH_PUNCTUATION = set( - r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" -) - - -LexemeKind = Literal["english", "number"] -URL_TOKEN_RE = re.compile(r"https?://[!-~]+") -ASCII_COMPOUND_TOKEN_RE = re.compile( - r"[A-Za-z0-9]+(?:[._/@:-][A-Za-z0-9]+)+" -) - - -@dataclass -class Stats: - word_count: int = 0 - char_count: int = 0 - han_chars: int = 0 - english_words: int = 0 - number_words: int = 0 - chinese_punctuations: int = 0 - english_letters: int = 0 - digits: int = 0 - english_punctuations: int = 0 - symbol_words: int = 0 - symbol_chars: int = 0 - - def to_dict(self) -> dict[str, object]: - return { - "word_count": self.word_count, - "char_count": self.char_count, - "breakdown": { - "han_chars": self.han_chars, - "english_words": self.english_words, - "number_words": self.number_words, - "chinese_punctuations": self.chinese_punctuations, - "english_letters": self.english_letters, - "digits": self.digits, - "english_punctuations": self.english_punctuations, - "symbol_words": self.symbol_words, - "symbol_chars": self.symbol_chars, - }, - } - - -def is_han(ch: str) -> bool: - code = ord(ch) - return ( - 0x3400 <= code <= 0x4DBF - or 0x4E00 <= code <= 0x9FFF - or 0xF900 <= code <= 0xFAFF - or 0x20000 <= code <= 0x2A6DF - or 0x2A700 <= code <= 0x2B73F - or 0x2B740 <= code <= 0x2B81F - or 0x2B820 <= code <= 0x2CEAF - or 0x30000 <= code <= 0x3134F - ) - - -def is_ascii_letter(ch: str) -> bool: - return ("a" <= ch <= "z") or ("A" <= ch <= "Z") - - -def is_digit(ch: str) -> bool: - return "0" <= ch <= "9" - - -def is_chinese_punctuation(ch: str) -> bool: - if ch in CHINESE_PUNCTUATION: - return True - return unicodedata.category(ch).startswith("P") and unicodedata.east_asian_width(ch) in { - "W", - "F", - } - - -def is_english_punctuation(ch: str) -> bool: - return ch in ENGLISH_PUNCTUATION - - -def is_unicode_symbol(ch: str) -> bool: - return unicodedata.category(ch).startswith("S") - - -def utf16_units(ch: str) -> int: - return len(ch.encode("utf-16-le")) // 2 - - -class Counter: - def __init__(self) -> None: - self.stats = Stats() - self._lexeme_kind: LexemeKind | None = None - self._lexeme_has_digit = False - self._symbol_run_length = 0 - self._at_boundary = True - - def count_segments(self, segments: list[Segment]) -> Stats: - for segment in segments: - if segment.boundary_before: - self._end_unit() - self._at_boundary = True - if segment.kind == "marker": - self.write_marker(segment.text) - elif segment.kind == "code": - self.write_code(segment.text) - else: - self.write(segment.text) - if segment.boundary_after: - self._end_unit() - self._at_boundary = True - self._end_unit() - return self.stats - - def write(self, text: str) -> None: - i = 0 - while i < len(text): - consumed = self._write_ascii_compound_token(text, i) - if consumed: - i += consumed - continue - if self._write_visible_ascii_separator(text, i): - i += 1 - continue - self._write_char(text[i]) - i += 1 - - def write_marker(self, text: str) -> None: - for ch in text: - if ch.isspace(): - continue - self._end_unit() - self.stats.word_count += 1 - self.stats.char_count += 1 - self._at_boundary = False - - def write_code(self, text: str) -> None: - for ch in text: - self._write_code_char(ch) - - def _write_code_char(self, ch: str) -> None: - if ch.isspace(): - self._end_unit() - self._at_boundary = True - return - - if is_han(ch): - self._end_lexeme() - self._end_symbol_run(count_word=False) - self.stats.han_chars += 1 - self.stats.word_count += 1 - self.stats.char_count += 1 - self._at_boundary = False - return - - if is_ascii_letter(ch): - self._end_symbol_run(count_word=False) - self.stats.english_letters += 1 - self.stats.char_count += 1 - if self._lexeme_kind is None: - self._lexeme_kind = "english" - elif self._lexeme_kind == "number": - self._lexeme_kind = "english" - self._at_boundary = False - return - - if is_digit(ch): - self._end_symbol_run(count_word=False) - self.stats.digits += 1 - self.stats.char_count += 1 - self._at_boundary = False - return - - if is_chinese_punctuation(ch): - self._end_lexeme() - self._end_symbol_run(count_word=False) - self.stats.chinese_punctuations += 1 - self.stats.word_count += 1 - self.stats.char_count += 1 - self._at_boundary = False - return - - if is_english_punctuation(ch): - keeps_lexeme = self._lexeme_kind == "english" and ch in {"'", "-"} - if not keeps_lexeme: - had_lexeme = self._lexeme_kind is not None - self._end_lexeme() - if not had_lexeme and (self._symbol_run_length > 0 or self._at_boundary): - self._symbol_run_length += 1 - self.stats.english_punctuations += 1 - self.stats.char_count += 1 - if keeps_lexeme: - self._at_boundary = False - return - - if is_unicode_symbol(ch): - self._write_symbol_char(ch) - return - - self._end_lexeme() - self._end_symbol_run(count_word=False) - self._at_boundary = False - - def _write_char(self, ch: str) -> None: - if ch.isspace(): - self._end_unit() - self._at_boundary = True - return - - if is_han(ch): - self._end_lexeme() - self._end_symbol_run(count_word=False) - self.stats.han_chars += 1 - self.stats.word_count += 1 - self.stats.char_count += 1 - self._at_boundary = False - return - - if is_ascii_letter(ch): - self._end_symbol_run(count_word=False) - self.stats.english_letters += 1 - self.stats.char_count += 1 - if self._lexeme_kind is None: - self._lexeme_kind = "english" - elif self._lexeme_kind == "number": - self._lexeme_kind = "english" - self._at_boundary = False - return - - if is_digit(ch): - self._end_symbol_run(count_word=False) - self.stats.digits += 1 - self.stats.char_count += 1 - self._lexeme_has_digit = True - if self._lexeme_kind is None: - self._lexeme_kind = "number" - self._at_boundary = False - return - - if is_chinese_punctuation(ch): - self._end_lexeme() - self._end_symbol_run(count_word=False) - self.stats.chinese_punctuations += 1 - self.stats.word_count += 1 - self.stats.char_count += 1 - self._at_boundary = False - return - - if is_english_punctuation(ch): - # Apostrophes/hyphens can connect English runs. Dot/comma/hyphen - # can format numeric runs such as 3.14, 1,000, 2026-06-30, or - # 7-9. Alphanumeric versions like v1.2.3 should remain one semantic - # run too. These punctuations still count as characters. - keeps_lexeme = ( - self._lexeme_kind == "english" - and (ch in {"'", "-"} or (self._lexeme_has_digit and ch == ".")) - ) or ( - self._lexeme_kind == "number" - and ch in {".", ",", "-"} - ) - if not keeps_lexeme: - had_lexeme = self._lexeme_kind is not None - self._end_lexeme() - if not had_lexeme and (self._symbol_run_length > 0 or self._at_boundary): - self._symbol_run_length += 1 - self.stats.english_punctuations += 1 - self.stats.char_count += 1 - if keeps_lexeme: - self._at_boundary = False - return - - if is_unicode_symbol(ch): - self._write_symbol_char(ch) - return - - self._end_lexeme() - self._end_symbol_run(count_word=False) - self._at_boundary = False - - def _write_visible_ascii_separator(self, text: str, index: int) -> bool: - ch = text[index] - if ch != "/" or index == 0 or index + 1 >= len(text): - return False - if not is_han(text[index - 1]) or not is_han(text[index + 1]): - return False - - self._end_unit() - self.stats.english_punctuations += 1 - self.stats.symbol_words += 1 - self.stats.word_count += 1 - self.stats.char_count += 1 - self._at_boundary = False - return True - - def _write_ascii_compound_token(self, text: str, start: int) -> int: - token = self._match_ascii_compound_token(text, start) - if not token: - return 0 - - self._end_unit() - self.stats.english_words += 1 - self.stats.word_count += 1 - for ch in token: - if is_ascii_letter(ch): - self.stats.english_letters += 1 - self.stats.char_count += 1 - elif is_digit(ch): - self.stats.digits += 1 - self.stats.char_count += 1 - elif is_english_punctuation(ch): - self.stats.english_punctuations += 1 - self.stats.char_count += 1 - elif is_unicode_symbol(ch): - units = utf16_units(ch) - self.stats.symbol_chars += units - self.stats.char_count += units - elif is_chinese_punctuation(ch): - self.stats.chinese_punctuations += 1 - self.stats.char_count += 1 - elif is_han(ch): - self.stats.han_chars += 1 - self.stats.char_count += 1 - self._at_boundary = False - return len(token) - - def _match_ascii_compound_token(self, text: str, start: int) -> str | None: - match = URL_TOKEN_RE.match(text, start) - if match: - return match.group(0) - - match = ASCII_COMPOUND_TOKEN_RE.match(text, start) - if not match: - return None - token = match.group(0) - if any(is_ascii_letter(ch) for ch in token): - return token - return None - - def _write_symbol_char(self, ch: str) -> None: - self._end_lexeme() - self._end_symbol_run(count_word=False) - units = utf16_units(ch) - self.stats.symbol_words += 1 - self.stats.symbol_chars += units - self.stats.word_count += 1 - self.stats.char_count += units - self._at_boundary = False - - def _end_unit(self) -> None: - self._end_lexeme() - self._end_symbol_run(count_word=True) - - def _end_lexeme(self) -> None: - if self._lexeme_kind == "english": - self.stats.english_words += 1 - self.stats.word_count += 1 - elif self._lexeme_kind == "number": - self.stats.number_words += 1 - self.stats.word_count += 1 - self._lexeme_kind = None - self._lexeme_has_digit = False - - def _end_symbol_run(self, *, count_word: bool) -> None: - if self._symbol_run_length >= 1 and count_word: - self.stats.symbol_words += 1 - self.stats.word_count += 1 - if self._symbol_run_length: - self._at_boundary = False - self._symbol_run_length = 0 - -# --------------------------------------------------------------------------- -# Markdown parser -# --------------------------------------------------------------------------- - -HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$") -LIST_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+(.*)$") -QUOTE_RE = re.compile(r"^\s*>\s?(.*)$") -TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$") - - -def parse_markdown(source: str) -> list[Block]: - lines = source.splitlines() - blocks: list[Block] = [] - paragraph: list[str] = [] - i = 0 - - def flush_paragraph() -> None: - if paragraph: - blocks.append(Block(type="paragraph", text_runs=[TextRun(clean_inline(" ".join(paragraph)))])) - paragraph.clear() - - while i < len(lines): - line = lines[i] - stripped = line.strip() - if not stripped: - flush_paragraph() - i += 1 - continue - - if stripped.startswith("```") or stripped.startswith("~~~"): - flush_paragraph() - fence = stripped[:3] - code_lines: list[str] = [] - i += 1 - while i < len(lines) and not lines[i].strip().startswith(fence): - code_lines.append(lines[i]) - i += 1 - if i < len(lines): - i += 1 - blocks.append(Block(type="code", text_runs=[TextRun("\n".join(code_lines))])) - continue - - heading = HEADING_RE.match(line) - if heading: - flush_paragraph() - blocks.append(Block(type="heading", text_runs=[TextRun(clean_inline(heading.group(2)))])) - i += 1 - continue - - if _looks_like_table(lines, i): - flush_paragraph() - table, consumed = _parse_table(lines, i) - blocks.append(table) - i += consumed - continue - - item = LIST_RE.match(line) - if item: - flush_paragraph() - items: list[Block] = [] - while i < len(lines): - match = LIST_RE.match(lines[i]) - if not match: - break - items.append(Block(type="list_item", text_runs=[TextRun(clean_inline(match.group(1)))])) - i += 1 - blocks.append(Block(type="list", children=items)) - continue - - quote = QUOTE_RE.match(line) - if quote: - flush_paragraph() - quote_lines: list[str] = [] - while i < len(lines): - match = QUOTE_RE.match(lines[i]) - if not match: - break - quote_lines.append(match.group(1)) - i += 1 - blocks.append(Block(type="quote", text_runs=[TextRun(clean_inline(" ".join(quote_lines)))])) - continue - - paragraph.append(stripped) - i += 1 - - flush_paragraph() - return blocks - - -def clean_inline(text: str) -> str: - text = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", text) - text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) - text = re.sub(r"([*_`~]{1,3})(.*?)\1", r"\2", text) - text = text.replace("\\", "") - return text - - -def _looks_like_table(lines: list[str], i: int) -> bool: - return i + 1 < len(lines) and "|" in lines[i] and TABLE_SEP_RE.match(lines[i + 1]) is not None - - -def _parse_table(lines: list[str], i: int) -> tuple[Block, int]: - rows: list[Block] = [] - consumed = 0 - while i + consumed < len(lines): - line = lines[i + consumed] - stripped = line.strip() - if not stripped or "|" not in stripped: - break - if consumed == 1 and TABLE_SEP_RE.match(stripped): - consumed += 1 - continue - cells = [cell.strip() for cell in stripped.strip("|").split("|")] - row = Block( - type="tr", - children=[ - Block(type="table_cell", text_runs=[TextRun(clean_inline(cell))]) - for cell in cells - if cell - ], - ) - rows.append(row) - consumed += 1 - return Block(type="table", children=rows), consumed - -# --------------------------------------------------------------------------- -# XML parser -# --------------------------------------------------------------------------- - -INLINE_TAGS = { - "b", - "strong", - "i", - "em", - "u", - "s", - "del", - "span", - "text", - "plain_text", - "code", - "a", - "link", - "mention", - "mention-doc", - "mention-user", -} - -TYPE_ALIASES = { - "doc": "document", - "document": "document", - "fragment": "fragment", - "p": "paragraph", - "paragraph": "paragraph", - "heading": "heading", - "h1": "heading", - "h2": "heading", - "h3": "heading", - "h4": "heading", - "h5": "heading", - "h6": "heading", - "h7": "heading", - "h8": "heading", - "h9": "heading", - "ul": "list", - "ol": "list", - "li": "list_item", - "task": "task", - "todo": "list_item", - "blockquote": "quote", - "quote": "quote", - "br": "br", - "hr": "hr", - "title": "title", - "checkbox": "checkbox", - "grid": "grid", - "column": "column", - "table": "table", - "colgroup": "colgroup", - "col": "col", - "tr": "tr", - "td": "table_cell", - "th": "table_cell", - "pre": "code", - "code_block": "code", - "callout": "callout", - "figure": "figure", - "toggle": "toggle", - "img": "image", - "source": "source", - "file": "file", - "media": "media", - "latex": "latex", - "cite": "cite", - "bookmark": "bookmark", - "button": "button", - "whiteboard": "whiteboard", - "mermaid": "mermaid", - "plantuml": "plantuml", - "poll": "poll", - "isv": "isv", - "mindnote": "mindnote", - "diagram": "diagram", - "sheet": "sheet", - "bitable": "bitable", - "base-ref": "base_ref", - "base_ref": "base_ref", - "base-refer": "base_ref", - "base_refer": "base_ref", - "synced-reference": "synced_reference", - "synced_reference": "synced_reference", - "synced-source": "synced_source", - "synced_source": "synced_source", - "okr": "okr", - "chat-card": "chat_card", - "chat_card": "chat_card", - "sub_page_list": "sub-page-list", - "sub-page-list": "sub-page-list", -} - -SUBTYPE_ATTR_TAGS = { - "a", - "button", - "cite", - "img", - "sheet", - "source", - "whiteboard", - "base-ref", - "base_ref", - "base-refer", - "base_refer", - "synced-reference", - "synced_reference", - "synced-source", - "synced_source", - "okr", - "chat-card", - "chat_card", - "sub_page_list", - "sub-page-list", -} - -MAX_XML_INPUT_CHARS = 20_000_000 -FORBIDDEN_XML_DECL_RE = re.compile(r" str: - if "}" in tag: - return tag.rsplit("}", 1)[1] - return tag - - -def block_type_for(elem: ET.Element) -> str: - tag = local_name(elem.tag) - explicit = elem.attrib.get("block_type") - if explicit is None and tag not in SUBTYPE_ATTR_TAGS: - explicit = elem.attrib.get("type") - if explicit: - return TYPE_ALIASES.get(explicit, explicit) - return TYPE_ALIASES.get(tag, tag) - - -def ensure_safe_xml_source(source: str) -> None: - if len(source) > MAX_XML_INPUT_CHARS: - raise UserInputError( - f"XML input is too large ({len(source)} chars, limit {MAX_XML_INPUT_CHARS})" - ) - if FORBIDDEN_XML_DECL_RE.search(source): - raise UserInputError("XML input must not contain DOCTYPE or ENTITY declarations") - - -def parse_xml(source: str) -> list[Block]: - source = source.strip() - if not source: - return [] - ensure_safe_xml_source(source) - try: - root = ET.fromstring(source) - except ET.ParseError: - # docs +fetch raw output can occasionally include adjacent top-level - # blocks. Wrap them so standard ElementTree can parse the stream. - root = ET.fromstring(f"{source}") - return [_parse_block(root)] - - -def _parse_block(elem: ET.Element) -> Block: - block = Block(type=block_type_for(elem), attrs=dict(elem.attrib), raw=elem) - _collect_content(elem, block) - if not block.text_runs and not block.children: - if block.type == "image": - display = elem.attrib.get("caption") - else: - display = ( - elem.attrib.get("text") - or elem.attrib.get("name") - or elem.attrib.get("title") - or elem.attrib.get("alt") - or elem.attrib.get("caption") - ) - if display: - block.text_runs.append(TextRun(display, dict(elem.attrib))) - return block - - -def _collect_content(elem: ET.Element, block: Block) -> None: - if elem.text: - block.text_runs.append(TextRun(elem.text)) - - for child in list(elem): - tag = local_name(child.tag) - if tag == "br": - block.text_runs.append(TextRun("\n")) - elif tag in INLINE_TAGS: - _collect_inline(child, block) - else: - block.children.append(_parse_block(child)) - if child.tail: - block.text_runs.append(TextRun(child.tail)) - - -def _collect_inline(elem: ET.Element, block: Block) -> None: - if local_name(elem.tag) == "br": - block.text_runs.append(TextRun("\n", dict(elem.attrib))) - return - - display = ( - elem.attrib.get("text") - or elem.attrib.get("name") - or elem.attrib.get("title") - or elem.attrib.get("alt") - ) - if display: - block.text_runs.append(TextRun(display, dict(elem.attrib))) - return - - if elem.text: - block.text_runs.append(TextRun(elem.text, dict(elem.attrib))) - for child in list(elem): - _collect_inline(child, block) - if child.tail: - block.text_runs.append(TextRun(child.tail)) - -# --------------------------------------------------------------------------- -# Block extraction registry -# --------------------------------------------------------------------------- - -@dataclass -class ExtractContext: - unknown_blocks: list[UnknownBlock] = field(default_factory=list) - resource_texts: dict[str, str] = field(default_factory=dict) - - -class Handler(Protocol): - def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: - raise NotImplementedError - - -def block_id(block: Block) -> str | None: - for key in ("id", "block_id", "block-id", "token"): - value = block.attrs.get(key) - if isinstance(value, str) and value: - return value - return None - - -def runs_text(block: Block) -> str: - return "".join(run.text for run in block.text_runs) - - -def raw_tag(block: Block) -> str: - tag = getattr(getattr(block, "raw", None), "tag", "") or "" - if "}" in tag: - return tag.rsplit("}", 1)[1] - return tag - - -class TextBlockHandler: - def __init__(self, kind: str = "text") -> None: - self.kind = kind - - def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: - segments: list[Segment] = [] - text = runs_text(block) - if text.strip(): - segments.append( - Segment( - text=text, - block_type=block.type, - block_id=block_id(block), - kind=self.kind, - ) - ) - for child in block.children: - segments.extend(registry.extract(child, ctx)) - return segments - - -class ContainerHandler: - def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: - segments: list[Segment] = [] - text = runs_text(block) - if text.strip(): - segments.append( - Segment(text=text, block_type=block.type, block_id=block_id(block), kind="text") - ) - for child in block.children: - segments.extend(registry.extract(child, ctx)) - return segments - - -class ListHandler: - def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: - tag = raw_tag(block) - if tag not in {"ol", "ul"}: - return ContainerHandler().extract(block, registry, ctx) - - segments: list[Segment] = [] - text = runs_text(block) - if text.strip(): - segments.append( - Segment(text=text, block_type=block.type, block_id=block_id(block), kind="text") - ) - - next_seq = 1 - for child in block.children: - if child.type == "list_item": - if tag == "ol": - seq = child.attrs.get("seq") - if isinstance(seq, str) and seq.isdigit(): - marker = seq - next_seq = int(seq) + 1 - else: - marker = str(next_seq) - next_seq += 1 - segments.append( - Segment( - text=f"{marker}.", - block_type="list_marker", - block_id=block_id(child), - kind="text", - ) - ) - else: - segments.append( - Segment( - text="•", - block_type="list_marker", - block_id=block_id(child), - kind="marker", - ) - ) - segments.extend(registry.extract(child, ctx)) - return segments - - -class CheckboxHandler(TextBlockHandler): - def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: - return [ - Segment( - text="☑" if block.attrs.get("done") == "true" else "☐", - block_type="checkbox_marker", - block_id=block_id(block), - kind="marker", - ), - *super().extract(block, registry, ctx), - ] - - -class UnknownHandler: - def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: - ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block))) - return ContainerHandler().extract(block, registry, ctx) - - -class IgnoreHandler: - def __init__(self, action: str = "ignored") -> None: - self.action = action - - def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: - ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action=self.action)) - return [] - - -class TaskHandler: - def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: - task_id = block.attrs.get("task-id") or block.attrs.get("task_id") - if isinstance(task_id, str) and task_id: - text = ctx.resource_texts.get(f"task:{task_id}") - if text and text.strip(): - marker = "☑" if block.attrs.get("status") in {"done", "completed", "complete"} else "☐" - return [ - Segment(text=marker, block_type="task_marker", block_id=block_id(block), kind="marker"), - Segment(text=text, block_type="task", block_id=block_id(block), kind="resource_title"), - ] - - ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action="ignored_resource")) - return [] - - -class WhiteboardHandler: - def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: - board_type = block.attrs.get("type") - is_empty_resource_shell = not board_type and not block.children and not runs_text(block).strip() - action = ( - "ignored_resource" - if board_type in {"blank", "mermaid", "plantuml", "svg"} or is_empty_resource_shell - else "unsupported_resource" - ) - ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action=action)) - return [] - - -class SyncedSourceHandler: - def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: - if block.children: - segments: list[Segment] = [] - for child in block.children: - segments.extend(registry.extract(child, ctx)) - return segments - - ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action="unsupported_resource")) - return [] - - -class Registry: - def __init__(self) -> None: - self._handlers: dict[str, Handler] = {} - self._unknown = UnknownHandler() - - def register(self, *types: str, handler: Handler) -> None: - for typ in types: - self._handlers[typ] = handler - - def extract(self, block: Block, ctx: ExtractContext) -> list[Segment]: - handler = self._handlers.get(block.type, self._unknown) - return handler.extract(block, self, ctx) - - -def default_registry() -> Registry: - registry = Registry() - registry.register("document", "fragment", "root", handler=ContainerHandler()) - registry.register("title", handler=TextBlockHandler("title")) - registry.register("paragraph", "p", handler=TextBlockHandler("text")) - registry.register( - "heading", - "h", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "h7", - "h8", - "h9", - handler=TextBlockHandler("heading"), - ) - registry.register("list", "ul", "ol", handler=ListHandler()) - registry.register("list_item", "li", "todo", handler=TextBlockHandler("list_item")) - registry.register("checkbox", handler=CheckboxHandler("list_item")) - registry.register( - "quote", - "blockquote", - "callout", - "toggle", - "grid", - "column", - "figure", - handler=ContainerHandler(), - ) - registry.register("table", "thead", "tbody", "tr", handler=ContainerHandler()) - registry.register("table_cell", "td", "th", handler=TextBlockHandler("table_cell")) - registry.register("code", "code_block", "pre", handler=TextBlockHandler("code")) - registry.register("link", "a", "mention", "mention-doc", "mention-user", "time", handler=TextBlockHandler("inline")) - registry.register("image", "img", handler=TextBlockHandler("caption")) - registry.register("colgroup", "col", "br", "hr", handler=IgnoreHandler("ignored_structure")) - registry.register("button", "cite", "latex", "bookmark", handler=IgnoreHandler("ignored_inline")) - registry.register("task", handler=TaskHandler()) - registry.register("whiteboard", handler=WhiteboardHandler()) - registry.register("synced_source", handler=SyncedSourceHandler()) - registry.register( - "mermaid", - "sheet", - "source", - "file", - "media", - "chat_card", - "base_ref", - "bitable", - "synced_reference", - "poll", - "isv", - "mindnote", - "diagram", - "sub-page-list", - handler=IgnoreHandler("ignored_resource"), - ) - registry.register( - "okr", - "plantuml", - handler=IgnoreHandler("unsupported_resource"), - ) - return registry - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - -VERSION = "0.1-alpha" - - -def build_diagnostics(items: list) -> dict[str, object]: - actions: dict[str, int] = {} - types: dict[str, int] = {} - unsupported_types: dict[str, int] = {} - unknown_types: dict[str, int] = {} - for item in items: - actions[item.action] = actions.get(item.action, 0) + 1 - types[item.type] = types.get(item.type, 0) + 1 - if item.action == "unsupported_resource": - unsupported_types[item.type] = unsupported_types.get(item.type, 0) + 1 - if item.action == "recurse_children": - unknown_types[item.type] = unknown_types.get(item.type, 0) + 1 - return { - "actions": actions, - "types": types, - "unsupported_types": unsupported_types, - "unknown_types": unknown_types, - "has_unsupported": bool(unsupported_types), - "has_unknown": bool(unknown_types), - } - - -def read_input(path: str) -> str: - if path == "-": - return sys.stdin.read() - return Path(path).read_text(encoding="utf-8") - - -def read_resource_texts(path: str | None) -> dict[str, str]: - if not path: - return {} - payload = json.loads(Path(path).read_text(encoding="utf-8")) - if not isinstance(payload, dict): - raise ValueError("--resource-texts must be a JSON object") - return {str(key): str(value) for key, value in payload.items()} - - -def extract_lark_json_content(source: str) -> str: - try: - envelope = json.loads(source) - except json.JSONDecodeError as exc: - raise UserInputError(f"could not parse lark-cli JSON envelope: {exc}") from exc - - if not isinstance(envelope, dict): - raise UserInputError("lark-cli JSON envelope must be an object") - data = envelope.get("data") - if not isinstance(data, dict): - raise UserInputError("lark-cli JSON envelope is missing object field data") - document = data.get("document") - if not isinstance(document, dict): - raise UserInputError("lark-cli JSON envelope is missing object field data.document") - content = document.get("content") - if not isinstance(content, str): - raise UserInputError("lark-cli JSON envelope is missing string field data.document.content") - return content - - -HELP_EPILOG = """ -Examples: - Local XML file: - python3 doc_word_stat.py --protocol xml /absolute/path/doc.xml - - Local Markdown file: - python3 doc_word_stat.py --protocol md /absolute/path/doc.md - - Pipe an extracted local file: - cat /absolute/path/doc.xml | python3 doc_word_stat.py --protocol xml --pretty - - Lark CLI XML fetch, JSON envelope output: - lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \\ - | python3 doc_word_stat.py --protocol xml --lark-json --pretty - - Lark CLI Markdown fetch, raw content output: - lark-cli docs +fetch --doc "$URL" --doc-format markdown \\ - | python3 doc_word_stat.py --protocol md - - Strict integration for agents or automation: - lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \\ - | python3 doc_word_stat.py --protocol xml --lark-json --fail-on-unsupported --fail-on-unknown -""" - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Count semantic words and visible characters in Lark Docs XML or Markdown.", - epilog=HELP_EPILOG, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "--version", - action="version", - version=f"%(prog)s {VERSION}", - ) - parser.add_argument( - "input", - nargs="?", - default="-", - help="input file path, or '-' / omitted for stdin", - ) - parser.add_argument( - "--protocol", - choices=("xml", "md"), - required=True, - help="input protocol produced by docs +fetch", - ) - parser.add_argument( - "--pretty", - action="store_true", - help="pretty-print JSON output", - ) - parser.add_argument( - "--segments", - action="store_true", - help="include extracted text segments for debugging", - ) - parser.add_argument( - "--lark-json", - action="store_true", - help="read lark-cli docs +fetch JSON and count data.document.content", - ) - parser.add_argument( - "--resource-texts", - help='optional JSON object mapping resource keys to visible text, e.g. {"task:": "title"}', - ) - parser.add_argument( - "--fail-on-unsupported", - action="store_true", - help="exit with code 2 when unsupported_blocks is non-empty", - ) - parser.add_argument( - "--fail-on-unknown", - action="store_true", - help="exit with code 3 when unknown XML/Markdown block types are encountered", - ) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - source = read_input(args.input) - if args.lark_json: - try: - source = extract_lark_json_content(source) - except UserInputError as exc: - print(f"error: {exc}", file=sys.stderr) - return 1 - - if args.protocol == "xml": - try: - blocks = parse_xml(source) - except (ET.ParseError, UserInputError) as exc: - print(f"error: could not parse XML input: {exc}", file=sys.stderr) - return 1 - else: - blocks = parse_markdown(source) - - ctx = ExtractContext(resource_texts=read_resource_texts(args.resource_texts)) - registry = default_registry() - segments = [] - for block in blocks: - segments.extend(registry.extract(block, ctx)) - - stats = Counter().count_segments(segments) - payload = stats.to_dict() - payload["protocol"] = args.protocol - payload["unknown_blocks"] = [item.to_dict() for item in ctx.unknown_blocks] - payload["unsupported_blocks"] = [ - item.to_dict() for item in ctx.unknown_blocks if item.action == "unsupported_resource" - ] - payload["diagnostics"] = build_diagnostics(ctx.unknown_blocks) - if args.segments: - payload["segments"] = [segment.to_dict() for segment in segments] - - indent = 2 if args.pretty else None - print(json.dumps(payload, ensure_ascii=False, indent=indent, sort_keys=args.pretty)) - if args.fail_on_unsupported and payload["unsupported_blocks"]: - return 2 - if args.fail_on_unknown and payload["diagnostics"]["has_unknown"]: - return 3 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) - diff --git a/tests/cli_e2e/docs/coverage.md b/tests/cli_e2e/docs/coverage.md index 77e243c179..73a9f6b512 100644 --- a/tests/cli_e2e/docs/coverage.md +++ b/tests/cli_e2e/docs/coverage.md @@ -1,33 +1,38 @@ # Docs CLI E2E Coverage ## Metrics -- Denominator: 11 leaf commands -- Covered: 6 -- Coverage: 54.5% +- Denominator: 12 leaf commands +- Covered: 7 +- Coverage: 58.3% ## Summary - TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`. - TestDocs_CreateAndFetchWorkflowAsUser: proves the same shortcut pair with UAT injection via `create as user` and `fetch as user`; creates its own Drive folder fixture first, then reads back the created doc by token. - TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes. +- TestDocs_LocalResourcesWorkflowAsBot / AsUser: prove the full local image + file lifecycle for `docs +create` and `docs +update --command append|block_replace`: placeholder correlation, distinct media block IDs, local image intrinsic-dimension detection, model display-size conversion to persisted `scale`, invalid `width`/`height`/`size` normalization, media upload, token binding, response scrubbing, XML/Markdown fetch verification, exported-Markdown replay with image caption restoration, and cleanup. +- TestDocs_LocalResourcesDryRun: proves `docs +create` and `docs +update --command append|overwrite|block_replace` expose the complete no-network request plan for local images and files: placeholder content with intrinsic dimensions, media uploads, image binding with intrinsic `width`/`height` plus converted `scale`, file binding, conditional verification, and failure cleanup. - TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`. - TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `...` tag to request body `content`. +- TestDocsScriptInitDraftCreatesUniqueWorkspacesWithoutXML, TestDocsScriptInitDraftDryRunDoesNotWrite, TestDocsScriptFileNameFlagIsRemoved, TestDocsScriptParseXMLFromFile, TestDocsScriptLocalParseWithAuthenticatedBot, TestDocsScriptRemoteImagePreflightDryRunDeclaresNetwork, TestDocsScriptInitializedDraftAutomaticallyValidatesPresentationDecision, TestDocsScriptParseRepairsMalformedXMLForProfile, TestDocsScriptStrictFlagIsRemoved, TestDocsScriptMarkdownToXMLIsRemoved, TestDocsScriptCreateTempXMLIsRemoved, TestDocsScriptParseAcceptsLocalImagePath, TestDocsScriptParseDoesNotSupportLegacyQAImage, TestDocsScriptParseAcceptsServerSDKAttributeAmpersand, TestDocsScriptParseRejectsMarkdownFromFile, TestDocsScriptDryRunIsLocal, TestDocsScriptOnlineDryRunFetchesXML, and TestDocs_CreateAndFetchWorkflowAsBot/script parse by token prove `docs +script` initializes distinct `draft__folder/draft.xml` workspaces under concurrency, reserves the XML path without creating the file, accepts the decision through `@file`, returns `ok:true` and exit code 0 for completed checks while separating pass/fail through `data.assessment.status`, reports word-count, required-block, and resource failures as structured `data.diagnostics[]`, groups same-cause remote image failures into one diagnostic with `image_indices[]`, keeps dry-run side-effect free, rejects the removed `--file-name`, `create-temp-xml`, and `markdown-to-xml` surfaces, exposes only tolerant parsing without `--strict`, accepts the service SDK's bare-ampersand URL attribute form and local `` syntax, does not recognize legacy `qa_image`, counts known block tags without enforcing business schema, profiles malformed XML through deterministic recovery, parses local XML with authenticated bot configuration, rejects Markdown input, reports external remote-image preflight network use accurately, fetches online document URL/token input as XML, and returns word/character/block profiles. +- TestDocsScriptPresentationDecisionListCountsULAndOL proves the compatibility-only `list` requirement aggregates `
    ` and `
      ` block counts. - TestDocs_DryRunDefaultsToV2OpenAPI also proves `docs +history-list`, `docs +history-revert`, and `docs +history-revert-status` dry-run endpoint and query/body shapes. - TestDocs_HistoryWorkflow proves the guarded live history flow (`LARK_DOC_HISTORY_E2E=1`): create, update, list prior revisions, revert, poll status when needed, and fetch to verify reverted content. - Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here. -- Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration. +- Blocked area: standalone media and search shortcuts still need dedicated deterministic workflows; local resource authoring through create/update is covered. ## Command Table | Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason | | --- | --- | --- | --- | --- | --- | -| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content | -| ✓ | docs +fetch | shortcut | docs_fetch_dryrun_test.go::TestDocsFetchDryRunIgnoresAPIVersionCompatFlag; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc `; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true`; `--api-version v1` compatibility flag still dry-runs the v2 fetch endpoint | | +| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_local_resources_workflow_test.go::TestDocs_LocalResourcesWorkflowAsBot/create image and source; docs_local_resources_workflow_test.go::TestDocs_LocalResourcesWorkflowAsUser/create image and source; docs_local_resources_dryrun_test.go::TestDocs_LocalResourcesDryRun/create; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent; docs_update_dryrun_test.go::TestDocs_CreateEmptyContentFileReportsActionableError | `--parent-token`; `--doc-format markdown`; `--content`; `--title`; XML `` + `` | local-resource workflows assert returned image/file block IDs and bound tokens; image binding preserves intrinsic dimensions and converts display size to `scale`; empty `@file` content reports the file path and `init-draft` recovery action | +| ✓ | docs +fetch | shortcut | docs_fetch_dryrun_test.go::TestDocsFetchDryRunIgnoresAPIVersionCompatFlag; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsBot/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_local_resources_workflow_test.go::testDocsLocalResourcesWorkflow/fetch verifies persisted resources; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc `; `--doc-format markdown` or `--doc-format xml`; `--detail full`; default `extra_param.enable_user_cite_reference_map=true`; `--api-version v1` compatibility flag still dry-runs the v2 fetch endpoint | local-resource fetch asserts captions/file names persist and internal markers/paths do not leak | | ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` | | ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` | | ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` | +| ✓ | docs +script | shortcut | docs_script_test.go::TestDocsScriptPresentationDecisionListCountsULAndOL; docs_script_test.go::TestDocsScriptInitDraftCreatesUniqueWorkspacesWithoutXML; docs_script_test.go::TestDocsScriptInitDraftDryRunDoesNotWrite; docs_script_test.go::TestDocsScriptFileNameFlagIsRemoved; docs_script_test.go::TestDocsScriptInitializedDraftAutomaticallyValidatesPresentationDecision; docs_script_test.go::TestDocsScriptInitializedDraftPreflightsBlockedRemoteImage; docs_script_test.go::TestDocsScriptParseRepairsMalformedXMLForProfile; docs_script_test.go::TestDocsScriptStrictFlagIsRemoved; docs_script_test.go::TestDocsScriptMarkdownToXMLIsRemoved; docs_script_test.go::TestDocsScriptCreateTempXMLIsRemoved; docs_script_test.go::TestDocsScriptParseRejectsMarkdownFromFile; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsBot/script parse by token | `--command init-draft` with `--presentation-decision `, `@file`, or `-`; `--command parse --content @file`; `--doc `; local/online dry-run | initializes `draft__folder/draft.xml` and reserves the uncreated XML path; validates word-count and planned block minimums; treats compatibility type `list` as the combined `
        ` and `
          ` count; preflights local/remote resources; returns `ok:true` with `assessment.status:failed` and structured diagnostics when checks do not pass; deduplicates same-cause image failures into `image_indices[]`; requires normal authentication for local parse, rejects Markdown input, and fetches online input as XML | | ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet | | ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions | | ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture | | ✕ | docs +search | shortcut | | none | search results are ambient and not yet stabilized for E2E | -| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/update | `--doc`; `--command overwrite`; `--doc-format markdown`; `--content`; optional `--reference-map` -> body `reference_map` | | +| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot; docs_local_resources_workflow_test.go::testDocsLocalResourcesWorkflow/append, block_replace, and overwrite image/source; docs_local_resources_dryrun_test.go::TestDocs_LocalResourcesDryRun/update append, overwrite, and block_replace; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/update | `--doc`; `--command overwrite`, `append`, or `block_replace`; `--doc-format markdown` or `--doc-format xml`; `--content`; local `` / ``; optional `--reference-map` -> body `reference_map` | local resources are covered under both bot and user identities | | ✕ | docs +whiteboard-update | shortcut | | none | requires whiteboard fixture and DSL-specific assertions | diff --git a/tests/cli_e2e/docs/docs_create_fetch_test.go b/tests/cli_e2e/docs/docs_create_fetch_test.go index e292615abf..f7b702a8c6 100644 --- a/tests/cli_e2e/docs/docs_create_fetch_test.go +++ b/tests/cli_e2e/docs/docs_create_fetch_test.go @@ -53,6 +53,24 @@ func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) { assert.Contains(t, content, docTitle) assert.Contains(t, content, "This document was created by lark-cli e2e test.") }) + + t.Run("script parse by token", func(t *testing.T) { + require.NotEmpty(t, docToken, "document token should be created before script parse") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "parse", + "--doc", docToken, + }, + DefaultAs: defaultAs, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assert.Positive(t, gjson.Get(result.Stdout, "data.profile.word_count").Int()) + assert.Positive(t, gjson.Get(result.Stdout, "data.profile.block_count").Int()) + }) } func TestDocs_CreateAndFetchWorkflowAsUser(t *testing.T) { diff --git a/tests/cli_e2e/docs/docs_local_resources_dryrun_test.go b/tests/cli_e2e/docs/docs_local_resources_dryrun_test.go new file mode 100644 index 0000000000..3692242119 --- /dev/null +++ b/tests/cli_e2e/docs/docs_local_resources_dryrun_test.go @@ -0,0 +1,173 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package docs + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" +) + +func TestDocs_LocalResourcesDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + workDir := t.TempDir() + writeLocalResourceFixture(t, workDir, "dry-run.png", hundredByEightyPNG) + writeLocalResourceFixture(t, workDir, "dry-run.txt", []byte("dry-run source fixture\n")) + content := `

          dry-run resources

          ` + + tests := []struct { + name string + args []string + wantDocumentURL string + wantCommand string + }{ + { + name: "create", + args: []string{ + "docs", "+create", + "--title", "Local resources dry-run", + "--content", content, + "--dry-run", + }, + wantDocumentURL: "/open-apis/docs_ai/v1/documents", + }, + { + name: "update append", + args: []string{ + "docs", "+update", + "--doc", "doxcnLocalResourcesDryRun", + "--command", "append", + "--content", content, + "--dry-run", + }, + wantDocumentURL: "/open-apis/docs_ai/v1/documents/doxcnLocalResourcesDryRun", + wantCommand: "block_insert_after", + }, + { + name: "update overwrite", + args: []string{ + "docs", "+update", + "--doc", "doxcnLocalResourcesDryRun", + "--command", "overwrite", + "--content", content, + "--dry-run", + }, + wantDocumentURL: "/open-apis/docs_ai/v1/documents/doxcnLocalResourcesDryRun", + wantCommand: "overwrite", + }, + { + name: "update block replace", + args: []string{ + "docs", "+update", + "--doc", "doxcnLocalResourcesDryRun", + "--command", "block_replace", + "--block-id", "blkLocalResourceTarget", + "--content", content, + "--dry-run", + }, + wantDocumentURL: "/open-apis/docs_ai/v1/documents/doxcnLocalResourcesDryRun", + wantCommand: "block_replace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "bot", + WorkDir: workDir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + apis := clie2e.DryRunGet(result.Stdout, "api").Array() + require.Len(t, apis, 6, "stdout:\n%s", result.Stdout) + require.Equal(t, tt.wantDocumentURL, apis[0].Get("url").String(), "stdout:\n%s", result.Stdout) + if tt.wantCommand != "" { + require.Equal(t, tt.wantCommand, apis[0].Get("body.command").String(), "stdout:\n%s", result.Stdout) + } + if tt.wantCommand == "block_replace" { + require.Equal(t, "blkLocalResourceTarget", apis[0].Get("body.block_id").String(), "stdout:\n%s", result.Stdout) + } + + preparedContent := apis[0].Get("body.content").String() + require.Contains(t, preparedContent, "dry-run image") + require.Contains(t, preparedContent, "dry-run-report.txt") + require.NotContains(t, preparedContent, "@dry-run.png") + require.NotContains(t, preparedContent, "@dry-run.txt") + require.Equal(t, 2, strings.Count(preparedContent, "@lcli_"), "prepared content:\n%s", preparedContent) + + require.Equal(t, "/open-apis/drive/v1/medias/upload_all", apis[1].Get("url").String()) + require.Equal(t, "docx_image", apis[1].Get("body.parent_type").String()) + require.Equal(t, "", apis[1].Get("body.parent_node").String()) + require.Equal(t, "/open-apis/drive/v1/medias/upload_all", apis[2].Get("url").String()) + require.Equal(t, "docx_file", apis[2].Get("body.parent_type").String()) + require.Equal(t, "", apis[2].Get("body.parent_node").String()) + + require.Contains(t, apis[3].Get("url").String(), "/open-apis/docx/v1/documents/") + require.Contains(t, apis[3].Get("url").String(), "/blocks/batch_update") + require.NotEmpty(t, apis[3].Get("params.client_token").String()) + require.Equal(t, "", apis[3].Get("body.requests.0.replace_image.token").String()) + require.Equal(t, int64(100), apis[3].Get("body.requests.0.replace_image.width").Int()) + require.Equal(t, int64(80), apis[3].Get("body.requests.0.replace_image.height").Int()) + require.InDelta(t, 0.5, apis[3].Get("body.requests.0.replace_image.scale").Float(), 0.000001) + require.Equal(t, "", apis[3].Get("body.requests.1.replace_file.token").String()) + + require.Equal(t, "GET", apis[4].Get("method").String()) + require.Equal(t, "PUT", apis[5].Get("method").String()) + require.Contains(t, apis[5].Get("url").String(), "/open-apis/docs_ai/v1/documents/") + require.Equal(t, "block_delete", apis[5].Get("body.command").String()) + }) + } +} + +func TestDocs_RemoteImageDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+create", + "--title", "Remote image dry-run", + "--content", `

          before

          `, + "--dry-run", + }, + DefaultAs: "bot", + WorkDir: t.TempDir(), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + apis := clie2e.DryRunGet(result.Stdout, "api").Array() + require.Len(t, apis, 6, "stdout:\n%s", result.Stdout) + require.Equal(t, "POST", apis[0].Get("method").String()) + require.Equal(t, "/open-apis/docs_ai/v1/documents", apis[0].Get("url").String()) + preparedContent := apis[0].Get("body.content").String() + require.NotContains(t, preparedContent, `href=`) + require.Contains(t, preparedContent, "@lcli_img_") + require.Contains(t, preparedContent, `caption="remote image"`) + + require.Equal(t, "GET", apis[1].Get("method").String()) + require.Equal(t, "https://93.184.216.34/photo.png", apis[1].Get("url").String()) + require.Contains(t, apis[1].Get("desc").String(), "query") + require.Contains(t, apis[1].Get("desc").String(), "bounded concurrent upload worker") + + require.Equal(t, "/open-apis/drive/v1/medias/upload_all", apis[2].Get("url").String()) + require.Equal(t, "docx_image", apis[2].Get("body.parent_type").String()) + require.Equal(t, "", apis[2].Get("body.parent_node").String()) +} diff --git a/tests/cli_e2e/docs/docs_local_resources_workflow_test.go b/tests/cli_e2e/docs/docs_local_resources_workflow_test.go new file mode 100644 index 0000000000..174f749fff --- /dev/null +++ b/tests/cli_e2e/docs/docs_local_resources_workflow_test.go @@ -0,0 +1,471 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package docs + +import ( + "bytes" + "context" + "fmt" + "html" + "image" + "image/png" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/larksuite/cli/tests/cli_e2e/drive" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDocs_LocalResourcesWorkflowAsBot(t *testing.T) { + // Bot-created documents are cleaned up with the test user because the + // shared bot intentionally has no Drive delete scope. + clie2e.SkipWithoutUserToken(t) + testDocsLocalResourcesWorkflow(t, "bot") +} + +func TestDocs_LocalResourcesWorkflowAsUser(t *testing.T) { + clie2e.SkipWithoutUserToken(t) + testDocsLocalResourcesWorkflow(t, "user") +} + +func testDocsLocalResourcesWorkflow(t *testing.T, defaultAs string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + t.Cleanup(cancel) + + workDir := t.TempDir() + createdSource := []byte("created source fixture\n") + appendedNegativeSource := []byte("appended negative source fixture\n") + appendedNonNumericSource := []byte("appended nonnumeric source fixture\n") + replacedSource := []byte("block replaced source fixture\n") + overwrittenSource := []byte("overwritten source fixture\n") + writeLocalResourceFixture(t, workDir, "created.png", hundredByEightyPNG) + writeLocalResourceFixture(t, workDir, "created.txt", createdSource) + writeLocalResourceFixture(t, workDir, "appended.png", onePixelPNG) + writeLocalResourceFixture(t, workDir, "replaced.png", onePixelPNG) + writeLocalResourceFixture(t, workDir, "replaced.txt", replacedSource) + writeLocalResourceFixture(t, workDir, "appended-negative.txt", appendedNegativeSource) + writeLocalResourceFixture(t, workDir, "appended-nonnumeric.txt", appendedNonNumericSource) + writeLocalResourceFixture(t, workDir, "overwritten.png", onePixelPNG) + writeLocalResourceFixture(t, workDir, "overwritten.txt", overwrittenSource) + + suffix := clie2e.GenerateSuffix() + parentT := t + folderToken := "" + cleanupAs := defaultAs + if defaultAs == "bot" { + // Bot-created documents grant the current CLI user full access, while + // the shared PPE bot intentionally lacks Drive delete scopes. + cleanupAs = "user" + } else { + folderToken = drive.CreateDriveFolder(t, parentT, ctx, "lark-cli-e2e-local-resources-"+suffix, defaultAs, "") + } + var docToken string + var roundTripDocToken string + var roundTripContent string + var appendedImageBlockID string + var appendedFileBlockID string + var appendedFileFigureBlockID string + + t.Run("create image and source", func(t *testing.T) { + args := []string{ + "docs", "+create", + "--title", "lark-cli local resources " + suffix, + "--content", `

          created resources

          `, + } + if folderToken != "" { + args = append(args, "--parent-token", folderToken) + } + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: args, + DefaultAs: defaultAs, + WorkDir: workDir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assertBoundLocalResourceBlocks(t, result.Stdout, 1, 1) + + docToken = gjson.Get(result.Stdout, "data.document.document_id").String() + require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout) + parentT.Cleanup(func() { + cleanupCtx, cleanupCancel := clie2e.CleanupContext() + defer cleanupCancel() + deleteResult, deleteErr := drive.DeleteDriveResourceAndVerify(cleanupCtx, docToken, "docx", cleanupAs) + clie2e.ReportCleanupFailure(parentT, "delete doc "+docToken, deleteResult, deleteErr) + }) + }) + + t.Run("append image and source", func(t *testing.T) { + require.NotEmpty(t, docToken, "document token should be created before update") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+update", + "--doc", docToken, + "--command", "append", + "--content", `

          appended resources

          `, + }, + DefaultAs: defaultAs, + WorkDir: workDir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assertBoundLocalResourceBlocks(t, result.Stdout, 1, 2) + appendedImageBlockID = localResourceBlockID(t, result.Stdout, "image") + appendedFileBlockID = localResourceBlockID(t, result.Stdout, "file") + appendedFileFigureBlockID = localResourceParentBlockID(ctx, t, defaultAs, docToken, appendedFileBlockID) + }) + + t.Run("block replace with local image", func(t *testing.T) { + require.NotEmpty(t, docToken, "document token should be created before update") + require.NotEmpty(t, appendedImageBlockID, "appended image block should be available before replacement") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+update", + "--doc", docToken, + "--command", "block_replace", + "--block-id", appendedImageBlockID, + "--content", ``, + }, + DefaultAs: defaultAs, + WorkDir: workDir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assertBoundLocalResourceBlocks(t, result.Stdout, 1, 0) + }) + + t.Run("block replace file child reports server reason", func(t *testing.T) { + require.NotEmpty(t, docToken, "document token should be created before update") + require.NotEmpty(t, appendedFileBlockID, "appended file block should be available before replacement") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+update", + "--doc", docToken, + "--command", "block_replace", + "--block-id", appendedFileBlockID, + "--content", ``, + }, + DefaultAs: defaultAs, + WorkDir: workDir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 1) + result.AssertStdoutStatus(t, false) + require.Equal(t, "failed", gjson.Get(result.Stdout, "data.result").String(), "stdout:\n%s", result.Stdout) + require.NotEmpty(t, gjson.Get(result.Stdout, "data.warnings").Array(), "server failure reason should remain visible: %s", result.Stdout) + }) + + t.Run("block replace with local file", func(t *testing.T) { + require.NotEmpty(t, docToken, "document token should be created before update") + require.NotEmpty(t, appendedFileFigureBlockID, "appended file figure should be available before replacement") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+update", + "--doc", docToken, + "--command", "block_replace", + "--block-id", appendedFileFigureBlockID, + "--content", ``, + }, + DefaultAs: defaultAs, + WorkDir: workDir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assertBoundLocalResourceBlocks(t, result.Stdout, 0, 1) + }) + + t.Run("fetch verifies persisted resources", func(t *testing.T) { + require.NotEmpty(t, docToken, "document token should be created before fetch") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+fetch", + "--doc", docToken, + "--doc-format", "xml", + "--detail", "full", + }, + DefaultAs: defaultAs, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + content := gjson.Get(result.Stdout, "data.document.content").String() + for _, want := range []string{ + "created image", + "block replaced image", + "created-report.txt", + "block-replaced-report.txt", + "appended-nonnumeric-report.txt", + } { + require.Contains(t, content, want, "fetched XML:\n%s", content) + } + require.NotContains(t, content, "@lcli_", "fetched XML leaked internal correlation marker") + require.NotContains(t, content, "@created.", "fetched XML leaked create fixture path") + require.NotContains(t, content, "@appended.", "fetched XML leaked append fixture path") + require.NotContains(t, content, "appended image", "replaced image caption remained in fetched XML") + require.NotContains(t, content, "appended-negative-report.txt", "replaced file name remained in fetched XML") + assertFetchedImagePresentation(t, content, "created image", 100, 80, 0.5) + }) + + t.Run("fetch markdown preserves resource metadata", func(t *testing.T) { + require.NotEmpty(t, docToken, "document token should be created before fetch") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+fetch", + "--doc", docToken, + "--doc-format", "markdown", + "--detail", "full", + }, + DefaultAs: defaultAs, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + content := gjson.Get(result.Stdout, "data.document.content").String() + for _, want := range []string{"![created image](", "![block replaced image]("} { + require.Contains(t, content, want, "fetched Markdown:\n%s", content) + } + assertMarkdownSourceMetadata(t, content, "created-report.txt", len(createdSource)) + assertMarkdownSourceMetadata(t, content, "block-replaced-report.txt", len(replacedSource)) + assertMarkdownSourceMetadata(t, content, "appended-nonnumeric-report.txt", len(appendedNonNumericSource)) + require.NotContains(t, content, "@lcli_", "fetched Markdown leaked internal correlation marker") + require.NotContains(t, content, "@created.", "fetched Markdown leaked create fixture path") + require.NotContains(t, content, "@appended.", "fetched Markdown leaked append fixture path") + + roundTripContent = content + }) + + t.Run("create from exported markdown restores image captions", func(t *testing.T) { + require.NotEmpty(t, roundTripContent, "Markdown content should be fetched before replay") + args := []string{ + "docs", "+create", + "--title", "lark-cli markdown replay " + suffix, + "--doc-format", "markdown", + "--content", "-", + } + if folderToken != "" { + args = append(args, "--parent-token", folderToken) + } + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: args, + DefaultAs: defaultAs, + Stdin: []byte(roundTripContent), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + roundTripDocToken = gjson.Get(result.Stdout, "data.document.document_id").String() + require.NotEmpty(t, roundTripDocToken, "stdout:\n%s", result.Stdout) + parentT.Cleanup(func() { + cleanupCtx, cleanupCancel := clie2e.CleanupContext() + defer cleanupCancel() + deleteResult, deleteErr := drive.DeleteDriveResourceAndVerify(cleanupCtx, roundTripDocToken, "docx", cleanupAs) + clie2e.ReportCleanupFailure(parentT, "delete markdown replay doc "+roundTripDocToken, deleteResult, deleteErr) + }) + }) + + t.Run("fetch markdown replay verifies captions and source metadata", func(t *testing.T) { + require.NotEmpty(t, roundTripDocToken, "Markdown replay document should be created before fetch") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+fetch", + "--doc", roundTripDocToken, + "--doc-format", "xml", + "--detail", "full", + }, + DefaultAs: defaultAs, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + content := gjson.Get(result.Stdout, "data.document.content").String() + for _, want := range []string{ + `caption="created image`, + `caption="block replaced image`, + } { + require.Contains(t, content, want, "replayed XML:\n%s", content) + } + for _, want := range []string{ + "created-report.txt", + "block-replaced-report.txt", + "appended-nonnumeric-report.txt", + } { + require.Contains(t, content, want, "replayed XML:\n%s", content) + } + }) + + t.Run("overwrite image and source", func(t *testing.T) { + require.NotEmpty(t, docToken, "document token should be created before overwrite") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+update", + "--doc", docToken, + "--command", "overwrite", + "--content", `

          overwritten resources

          `, + }, + DefaultAs: defaultAs, + WorkDir: workDir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assertBoundLocalResourceBlocks(t, result.Stdout, 1, 1) + }) + + t.Run("fetch verifies overwritten resources", func(t *testing.T) { + require.NotEmpty(t, docToken, "document token should be created before fetch") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+fetch", + "--doc", docToken, + "--doc-format", "xml", + "--detail", "full", + }, + DefaultAs: defaultAs, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + content := gjson.Get(result.Stdout, "data.document.content").String() + for _, want := range []string{"overwritten resources", "overwritten image", "overwritten-report.txt"} { + require.Contains(t, content, want, "fetched XML:\n%s", content) + } + for _, old := range []string{"created resources", "appended resources", "created-report.txt", "appended-negative-report.txt"} { + require.NotContains(t, content, old, "overwrite preserved old content:\n%s", content) + } + require.NotContains(t, content, "@lcli_", "fetched XML leaked internal correlation marker") + require.NotContains(t, content, "@overwritten.", "fetched XML leaked overwrite fixture path") + }) +} + +var markdownSourceTagPattern = regexp.MustCompile(`(?s)]*>`) + +func assertMarkdownSourceMetadata(t *testing.T, content, wantName string, wantSize int) { + t.Helper() + wantNameAttr := fmt.Sprintf(`name="%s"`, wantName) + for _, tag := range markdownSourceTagPattern.FindAllString(content, -1) { + if !strings.Contains(tag, wantNameAttr) { + continue + } + require.Contains(t, tag, fmt.Sprintf(`size="%d"`, wantSize), "source tag in fetched Markdown:\n%s", tag) + return + } + require.Failf(t, "source metadata not found", "fetched Markdown has no source tag with %s:\n%s", wantNameAttr, content) +} + +func assertBoundLocalResourceBlocks(t *testing.T, stdout string, wantImages, wantFiles int) { + t.Helper() + counts := map[string]int{"image": 0, "file": 0} + blockIDs := make(map[string]struct{}, wantImages+wantFiles) + for _, block := range gjson.Get(stdout, "data.document.new_blocks").Array() { + blockType := block.Get("block_type").String() + if _, tracked := counts[blockType]; !tracked { + continue + } + counts[blockType]++ + blockID := block.Get("block_id").String() + require.NotEmpty(t, blockID, "%s block has no block_id: %s", blockType, block.Raw) + require.NotContains(t, blockIDs, blockID, "multiple local resources reused block_id %s: %s", blockID, stdout) + blockIDs[blockID] = struct{}{} + token := block.Get("block_token").String() + require.NotEmpty(t, token, "%s block has no bound token: %s", blockType, block.Raw) + require.False(t, strings.HasPrefix(token, "@lcli_"), "%s block leaked marker: %s", blockType, block.Raw) + } + require.Equal(t, wantImages, counts["image"], "image blocks in stdout:\n%s", stdout) + require.Equal(t, wantFiles, counts["file"], "file blocks in stdout:\n%s", stdout) +} + +func localResourceBlockID(t *testing.T, stdout, blockType string) string { + t.Helper() + for _, block := range gjson.Get(stdout, "data.document.new_blocks").Array() { + if block.Get("block_type").String() == blockType { + blockID := block.Get("block_id").String() + require.NotEmpty(t, blockID, "%s block has no block_id: %s", blockType, block.Raw) + return blockID + } + } + require.Failf(t, "local resource block not found", "stdout has no %s block:\n%s", blockType, stdout) + return "" +} + +func localResourceParentBlockID(ctx context.Context, t *testing.T, defaultAs, docToken, childBlockID string) string { + t.Helper() + endpoint := fmt.Sprintf( + "/open-apis/docx/v1/documents/%s/blocks/%s", + url.PathEscape(docToken), + url.PathEscape(childBlockID), + ) + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{"api", "get", endpoint, "--params", `{"document_revision_id":-1}`}, + DefaultAs: defaultAs, + }, clie2e.RetryOptions{ + Attempts: 5, + InitialDelay: 250 * time.Millisecond, + MaxDelay: time.Second, + ShouldRetry: func(result *clie2e.Result) bool { + return result != nil && result.ExitCode != 0 + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + parentID := gjson.Get(result.Stdout, "data.block.parent_id").String() + require.NotEmpty(t, parentID, "file block %s has no figure parent: %s", childBlockID, result.Stdout) + require.NotEqual(t, childBlockID, parentID, "file replacement must target the enclosing figure") + return parentID +} + +func writeLocalResourceFixture(t *testing.T, dir, name string, data []byte) { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, data, 0o600)) +} + +func assertFetchedImagePresentation(t *testing.T, content, caption string, width, height int, scale float64) { + t.Helper() + for _, tag := range regexp.MustCompile(`(?s)]*>`).FindAllString(content, -1) { + captionMatch := regexp.MustCompile(`\bcaption="([^"]*)"`).FindStringSubmatch(tag) + if len(captionMatch) != 2 || strings.TrimSpace(html.UnescapeString(captionMatch[1])) != caption { + continue + } + require.Contains(t, tag, fmt.Sprintf(`width="%d"`, width), "image tag in fetched XML:\n%s", tag) + require.Contains(t, tag, fmt.Sprintf(`height="%d"`, height), "image tag in fetched XML:\n%s", tag) + scaleMatch := regexp.MustCompile(`\bscale="([^"]+)"`).FindStringSubmatch(tag) + require.Len(t, scaleMatch, 2, "image tag has no scale: %s", tag) + var gotScale float64 + _, err := fmt.Sscanf(scaleMatch[1], "%f", &gotScale) + require.NoError(t, err, "parse image scale from %s", tag) + require.InDelta(t, scale, gotScale, 0.000001, "image tag in fetched XML:\n%s", tag) + return + } + require.Failf(t, "image presentation not found", "fetched XML has no image with caption %q:\n%s", caption, content) +} + +func encodePNGFixture(width, height int) []byte { + var buf bytes.Buffer + if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, width, height))); err != nil { + panic(fmt.Sprintf("encode embedded %dx%d PNG fixture: %v", width, height, err)) + } + return buf.Bytes() +} + +var ( + onePixelPNG = encodePNGFixture(1, 1) + hundredByEightyPNG = encodePNGFixture(100, 80) +) diff --git a/tests/cli_e2e/docs/docs_script_test.go b/tests/cli_e2e/docs/docs_script_test.go new file mode 100644 index 0000000000..923c953897 --- /dev/null +++ b/tests/cli_e2e/docs/docs_script_test.go @@ -0,0 +1,576 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package docs + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDocsScriptParseXMLFromFile(t *testing.T) { + workDir := t.TempDir() + input := `标题

          一个苹果是 an apple。

          ` + if err := os.WriteFile(filepath.Join(workDir, "draft.xml"), []byte(input), 0o600); err != nil { + t.Fatalf("write draft: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "parse", + "--content", "@draft.xml", + }, + DefaultAs: "bot", + WorkDir: workDir, + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + require.Equal(t, "passed", gjson.Get(result.Stdout, "data.assessment.status").String()) + require.Equal(t, int64(10), gjson.Get(result.Stdout, "data.profile.word_count").Int()) + require.Equal(t, int64(15), gjson.Get(result.Stdout, "data.profile.char_count").Int()) + require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.profile.block_count").Int()) + require.False(t, gjson.Get(result.Stdout, "data.input_format").Exists()) + require.False(t, gjson.Get(result.Stdout, "data.command").Exists()) + require.False(t, gjson.Get(result.Stdout, "data.profile.breakdown").Exists()) + require.False(t, gjson.Get(result.Stdout, "data.profile.compatibility").Exists()) +} + +func TestDocsScriptLocalParseWithAuthenticatedBot(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "local.xml"), []byte(`

          local only

          `), 0o600)) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "parse", + "--content", "@local.xml", + }, + DefaultAs: "bot", + WorkDir: workDir, + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + require.Equal(t, "passed", gjson.Get(result.Stdout, "data.assessment.status").String()) + require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.profile.word_count").Int()) +} + +func TestDocsScriptPresentationDecisionListCountsULAndOL(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + decision := `{"audience":"reader","reader_task":"review recommendations","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"group recommendations","blocks":[{"type":"list","min_count":2,"purpose":"separate equipment and food recommendations"}]}}` + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "parse", + "--content", `
          • equipment
          1. food
          `, + "--presentation-decision", decision, + }, + DefaultAs: "bot", + WorkDir: t.TempDir(), + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + require.Equal(t, "passed", gjson.Get(result.Stdout, "data.assessment.status").String()) + require.False(t, gjson.Get(result.Stdout, "data.diagnostics").Exists()) + require.Equal(t, int64(1), gjson.Get(result.Stdout, `data.profile.blocks.#(type=="ul").count`).Int()) + require.Equal(t, int64(1), gjson.Get(result.Stdout, `data.profile.blocks.#(type=="ol").count`).Int()) +} + +func TestDocsScriptRemoteImagePreflightDryRunDeclaresNetwork(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"an image provides visual evidence","blocks":[{"type":"img","min_count":1,"purpose":"show visual evidence"}]}}` + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "parse", + "--content", `Draft`, + "--presentation-decision", decision, + "--dry-run", + }, + WorkDir: t.TempDir(), + DefaultAs: "bot", + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + require.True(t, gjson.Get(result.Stdout, "data.network").Bool()) + require.Equal(t, int64(0), gjson.Get(result.Stdout, "data.api.#").Int()) +} + +func TestDocsScriptInitializedDraftAutomaticallyValidatesPresentationDecision(t *testing.T) { + workDir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + decision := `{"audience":"reader","reader_task":"understand the result and flow","genre_contract":"none","adapter":null,"presentation_mode":"rich","word_count":{"min":18,"max":22},"visual_plan":{"reason":"visual explanation","blocks":[{"type":"img","min_count":1,"purpose":"show the result"},{"type":"whiteboard","min_count":1,"purpose":"show the flow"},{"type":"html5-block","min_count":1,"purpose":"make the state explorable"}]}}` + initialized, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "init-draft", + "--presentation-decision", decision, + }, + DefaultAs: "bot", + WorkDir: workDir, + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + initialized.AssertExitCode(t, 0) + initialized.AssertStdoutStatus(t, true) + draftPath := gjson.Get(initialized.Stdout, "data.draft_path").String() + workspace := gjson.Get(initialized.Stdout, "data.workspace").String() + require.NotEmpty(t, draftPath) + require.Equal(t, filepath.Dir(draftPath), workspace) + require.False(t, gjson.Get(initialized.Stdout, "data.draft_file_created").Exists()) + require.Equal(t, "The workspace directory has been created successfully. draft_path points to a new XML file that does not exist yet. Create and write the file directly without reading it first.", gjson.Get(initialized.Stdout, "data.tip").String()) + require.Len(t, gjson.Get(initialized.Stdout, "data").Map(), 3) + _, statErr := os.Stat(filepath.Join(workDir, draftPath)) + require.True(t, os.IsNotExist(statErr), "reserved draft XML already exists: %v", statErr) + require.NoError(t, os.WriteFile( + filepath.Join(workDir, draftPath), + []byte(`标题

          一个苹果是 an apple。

          `), + 0o600, + )) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "parse", + "--content", "@./" + draftPath, + }, + DefaultAs: "bot", + WorkDir: workDir, + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + require.Equal(t, "failed", gjson.Get(result.Stdout, "data.assessment.status").String()) + require.Equal(t, int64(3), gjson.Get(result.Stdout, "data.diagnostics.#").Int()) + require.Equal(t, "word_count_out_of_range", gjson.Get(result.Stdout, "data.diagnostics.0.code").String()) + require.Equal(t, int64(18), gjson.Get(result.Stdout, "data.diagnostics.0.expected.min").Int()) + require.Equal(t, int64(22), gjson.Get(result.Stdout, "data.diagnostics.0.expected.max").Int()) + require.Equal(t, int64(10), gjson.Get(result.Stdout, "data.diagnostics.0.actual").Int()) + require.Equal(t, "required_block_missing", gjson.Get(result.Stdout, "data.diagnostics.1.code").String()) + require.Equal(t, "whiteboard", gjson.Get(result.Stdout, "data.diagnostics.1.expected.type").String()) + require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.diagnostics.1.expected.min_count").Int()) + require.Equal(t, int64(0), gjson.Get(result.Stdout, "data.diagnostics.1.actual").Int()) + require.Equal(t, "html5-block", gjson.Get(result.Stdout, "data.diagnostics.2.expected.type").String()) +} + +func TestDocsScriptInitializedDraftPreflightsBlockedRemoteImage(t *testing.T) { + workDir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"an image provides visual evidence","blocks":[{"type":"img","min_count":1,"purpose":"show visual evidence"}]}}` + + initialized, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "init-draft", + "--presentation-decision", decision, + }, + DefaultAs: "user", + WorkDir: workDir, + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + initialized.AssertExitCode(t, 0) + draftPath := gjson.Get(initialized.Stdout, "data.draft_path").String() + workspace := gjson.Get(initialized.Stdout, "data.workspace").String() + require.NotEmpty(t, draftPath) + require.Equal(t, filepath.Dir(draftPath), workspace) + require.False(t, gjson.Get(initialized.Stdout, "data.draft_file_created").Exists()) + require.Equal(t, "The workspace directory has been created successfully. draft_path points to a new XML file that does not exist yet. Create and write the file directly without reading it first.", gjson.Get(initialized.Stdout, "data.tip").String()) + _, statErr := os.Stat(filepath.Join(workDir, draftPath)) + require.True(t, os.IsNotExist(statErr), "reserved draft XML already exists: %v", statErr) + require.NoError(t, os.WriteFile( + filepath.Join(workDir, draftPath), + []byte(`Draft`), + 0o600, + )) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "parse", + "--content", "@./" + draftPath, + }, + DefaultAs: "user", + WorkDir: workDir, + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + require.Equal(t, "failed", gjson.Get(result.Stdout, "data.assessment.status").String()) + require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.diagnostics.#").Int()) + require.Equal(t, "remote_image_source_disallowed", gjson.Get(result.Stdout, "data.diagnostics.0.code").String()) + require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.diagnostics.0.image_indices.#").Int()) + require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.diagnostics.0.image_indices.0").Int()) + require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.diagnostics.0.image_indices.1").Int()) + require.Equal(t, "local/internal host is not allowed", gjson.Get(result.Stdout, "data.diagnostics.0.msg").String()) + require.Contains(t, gjson.Get(result.Stdout, "data.diagnostics.0.suggested").String(), "Download the affected images") + require.False(t, gjson.Get(result.Stdout, "data.warning").Exists()) +} + +func TestDocsScriptParseRepairsMalformedXMLForProfile(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "parse", + "--content", `T
          • one
          • two

          tailtext

          `, + "--strict", + }, + DefaultAs: "bot", + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String()) + require.Equal(t, "--strict", gjson.Get(result.Stderr, "error.params.0.name").String()) + require.Equal(t, "unknown flag", gjson.Get(result.Stderr, "error.params.0.reason").String()) +} + +func TestDocsScriptFileNameFlagIsRemoved(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "init-draft", + "--file-name", "draft.xml", + "--presentation-decision", `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain text is sufficient","blocks":[]}}`, + }, + DefaultAs: "bot", + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String()) + require.Equal(t, "--file-name", gjson.Get(result.Stderr, "error.params.0.name").String()) + require.Equal(t, "unknown flag", gjson.Get(result.Stderr, "error.params.0.reason").String()) +} + +func TestDocsScriptMarkdownToXMLIsRemoved(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "markdown-to-xml", + "--content", "# title", + "--dry-run", + }, + DefaultAs: "bot", + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + require.Equal(t, "--command", gjson.Get(result.Stderr, "error.param").String()) + require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), `invalid value "markdown-to-xml"`) +} + +func TestDocsScriptCreateTempXMLIsRemoved(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "create-temp-xml", + "--dry-run", + }, + DefaultAs: "bot", + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + require.Equal(t, "--command", gjson.Get(result.Stderr, "error.param").String()) + require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), `invalid value "create-temp-xml"`) +} + +func TestDocsScriptParseAcceptsLocalImagePath(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "parse", + "--content", `Local image`, + }, + DefaultAs: "bot", + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.profile.block_count").Int()) + require.Equal(t, int64(1), gjson.Get(result.Stdout, `data.profile.blocks.#(type=="img").count`).Int()) +} + +func TestDocsScriptParseDoesNotSupportLegacyQAImage(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "parse", + "--content", ``, + }, + DefaultAs: "bot", + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + require.False(t, gjson.Get(result.Stdout, `data.profile.blocks.#(type=="img").count`).Exists()) +} + +func TestDocsScriptParseAcceptsServerSDKAttributeAmpersand(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + input := `-1` + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"docs", "+script", "--command", "parse", "--content", input}, + DefaultAs: "bot", + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.profile.block_count").Int()) +} + +func TestDocsScriptParseRejectsMarkdownFromFile(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "draft.md"), []byte("# 标题\n\n- item"), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "parse", + "--content", "@draft.md", + }, + DefaultAs: "bot", + WorkDir: workDir, + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + require.False(t, gjson.Get(result.Stderr, "ok").Bool()) + require.Equal(t, "--content", gjson.Get(result.Stderr, "error.param").String()) + require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), "as LarkOpenCLI XML") +} + +func TestDocsScriptInitDraftCreatesUniqueWorkspacesWithoutXML(t *testing.T) { + workDir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain text is sufficient","blocks":[]}}` + + const count = 8 + type outcome struct { + result *clie2e.Result + err error + } + outcomes := make(chan outcome, count) + for i := 0; i < count; i++ { + go func() { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", "--command", "init-draft", + "--presentation-decision", decision, + }, + DefaultAs: "bot", + WorkDir: workDir, + Env: docsScriptE2EEnv(t), + }) + outcomes <- outcome{result: result, err: err} + }() + } + + seen := make(map[string]struct{}, count) + for i := 0; i < count; i++ { + outcome := <-outcomes + require.NoError(t, outcome.err) + require.NotNil(t, outcome.result) + outcome.result.AssertExitCode(t, 0) + outcome.result.AssertStdoutStatus(t, true) + + path := gjson.Get(outcome.result.Stdout, "data.draft_path").String() + directory := filepath.Dir(path) + workspace := gjson.Get(outcome.result.Stdout, "data.workspace").String() + randomPart := strings.TrimSuffix(strings.TrimPrefix(directory, "draft_"), "_folder") + require.Equal(t, directory, workspace) + require.False(t, gjson.Get(outcome.result.Stdout, "data.draft_file_created").Exists()) + require.Equal(t, "The workspace directory has been created successfully. draft_path points to a new XML file that does not exist yet. Create and write the file directly without reading it first.", gjson.Get(outcome.result.Stdout, "data.tip").String()) + require.Equal(t, "draft.xml", filepath.Base(path)) + require.Equal(t, filepath.Base(directory), directory) + require.True(t, strings.HasPrefix(directory, "draft_"), "path: %q", path) + require.True(t, strings.HasSuffix(directory, "_folder"), "path: %q", path) + require.Len(t, randomPart, 8) + _, duplicate := seen[path] + require.False(t, duplicate, "duplicate draft path: %q", path) + seen[path] = struct{}{} + _, statErr := os.Stat(filepath.Join(workDir, path)) + require.True(t, os.IsNotExist(statErr), "reserved draft XML already exists: %q, err=%v", path, statErr) + savedDecision, err := os.ReadFile(filepath.Join(workDir, directory, ".presentation-decision.json")) + require.NoError(t, err) + require.Equal(t, decision, string(savedDecision)) + } + require.Len(t, seen, count) +} + +func TestDocsScriptDryRunIsLocal(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "parse", + "--content", `

          text

          `, + "--dry-run", + }, + DefaultAs: "bot", + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + require.Equal(t, int64(0), gjson.Get(result.Stdout, "data.api.#").Int()) + require.False(t, gjson.Get(result.Stdout, "data.network").Bool()) + require.False(t, gjson.Get(result.Stdout, "data.strict").Exists()) + require.Equal(t, "parse", gjson.Get(result.Stdout, "data.command").String()) + require.False(t, gjson.Get(result.Stdout, "data.presentation_decision").Exists()) +} + +func TestDocsScriptInitDraftDryRunDoesNotWrite(t *testing.T) { + workDir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "init-draft", + "--presentation-decision", `{"audience":"reader","reader_task":"understand the topic","genre_contract":"none","adapter":null,"presentation_mode":"rich","visual_plan":{"reason":"compatibility check for ordinary lists","blocks":[{"type":"list","min_count":1,"purpose":"group related items"}]}}`, + "--dry-run", + }, + DefaultAs: "bot", + WorkDir: workDir, + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + require.Equal(t, int64(0), gjson.Get(result.Stdout, "data.api.#").Int()) + require.False(t, gjson.Get(result.Stdout, "data.network").Bool()) + require.True(t, gjson.Get(result.Stdout, "data.creates_workspace").Bool()) + require.False(t, gjson.Get(result.Stdout, "data.creates_draft_file").Bool()) + require.Equal(t, "init-draft", gjson.Get(result.Stdout, "data.command").String()) + require.Equal(t, "draft_*_folder", gjson.Get(result.Stdout, "data.directory_pattern").String()) + require.Equal(t, "draft.xml", gjson.Get(result.Stdout, "data.xml_file_name").String()) + require.False(t, gjson.Get(result.Stdout, "data.file_name").Exists()) + require.True(t, gjson.Get(result.Stdout, "data.presentation_decision").Bool()) + entries, err := os.ReadDir(workDir) + require.NoError(t, err) + require.Empty(t, entries) +} + +func TestDocsScriptInitDraftRejectsNullWordCountWithOmitGuidance(t *testing.T) { + workDir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "init-draft", + "--presentation-decision", `{"audience":"reader","reader_task":"understand the topic","genre_contract":"none","adapter":null,"presentation_mode":"normal","word_count":null,"visual_plan":{"reason":"plain text is sufficient","blocks":[]}}`, + "--dry-run", + }, + DefaultAs: "bot", + WorkDir: workDir, + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + require.Equal(t, "--presentation-decision", gjson.Get(result.Stderr, "error.param").String()) + require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), "word_count must be omitted") + require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "remove the word_count field") + entries, readErr := os.ReadDir(workDir) + require.NoError(t, readErr) + require.Empty(t, entries) +} + +func TestDocsScriptOnlineDryRunFetchesXML(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "parse", + "--doc", "https://example.larksuite.com/docx/doxcnScriptDryRun", + "--dry-run", + }, + DefaultAs: "bot", + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.api.#").Int()) + require.Equal(t, "POST", gjson.Get(result.Stdout, "data.api.0.method").String()) + require.Equal(t, "/open-apis/docs_ai/v1/documents/doxcnScriptDryRun/fetch", gjson.Get(result.Stdout, "data.api.0.url").String()) + require.Equal(t, "xml", gjson.Get(result.Stdout, "data.api.0.body.format").String()) + require.True(t, gjson.Get(result.Stdout, "data.network").Bool()) + require.False(t, gjson.Get(result.Stdout, "data.strict").Exists()) + require.Equal(t, "parse", gjson.Get(result.Stdout, "data.command").String()) +} + +func docsScriptE2EEnv(t *testing.T) map[string]string { + t.Helper() + return map[string]string{ + "LARKSUITE_CLI_APP_ID": "docs-script-e2e", + "LARKSUITE_CLI_APP_SECRET": "secret", + "LARKSUITE_CLI_BRAND": "feishu", + "LARKSUITE_CLI_CONFIG_DIR": t.TempDir(), + } +} diff --git a/tests/cli_e2e/docs/docs_update_dryrun_test.go b/tests/cli_e2e/docs/docs_update_dryrun_test.go index 9dcc69d884..d1243af57c 100644 --- a/tests/cli_e2e/docs/docs_update_dryrun_test.go +++ b/tests/cli_e2e/docs/docs_update_dryrun_test.go @@ -5,6 +5,8 @@ package docs import ( "context" + "os" + "path/filepath" "strings" "testing" "time" @@ -259,3 +261,33 @@ func TestDocsUpdateDryRunLegacyFlagReturnsCurrentEmbeddedGuidance(t *testing.T) result.Stderr, ) } + +func TestDocs_CreateEmptyContentFileReportsActionableError(t *testing.T) { + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "draft.xml"), nil, 0o600)) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+create", + "--doc-format", "xml", + "--content", "@draft.xml", + "--dry-run", + }, + DefaultAs: "user", + WorkDir: workDir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + require.Empty(t, result.Stdout, "stdout must stay reserved for program data") + require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String()) + require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String()) + require.Equal(t, "--content", gjson.Get(result.Stderr, "error.param").String()) + require.Equal(t, `--content file "draft.xml" is empty`, gjson.Get(result.Stderr, "error.message").String()) + require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "exact data.draft_path returned by that command") +}