355 lines
13 KiB
Go
355 lines
13 KiB
Go
package helpers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers/doc"
|
|
jsonrepair "github.com/RealAlexandreAI/json-repair"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
repairJSONML = jsonrepair.RepairJSON
|
|
diagnoseJSONMLError = doc.DiagnoseJSONError
|
|
)
|
|
|
|
// isInputDangerousUnicode matches the dangerous-unicode set defined by the
|
|
// server-side RejectControlChars validator (pkg/validate/input.go).
|
|
func isInputDangerousUnicode(r rune) bool {
|
|
switch {
|
|
case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner
|
|
return true
|
|
case r == 0xFEFF: // BOM / ZWNBSP
|
|
return true
|
|
case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO
|
|
return true
|
|
case r >= 0x2028 && r <= 0x2029: // line/paragraph separator
|
|
return true
|
|
case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// stripInputUnsafeChars removes characters that would be rejected by the
|
|
// server-side RejectControlChars validator:
|
|
// 1. C0 control characters (except \t and \n) and DEL (0x7F)
|
|
// 2. Dangerous Unicode characters (zero-width, Bidi, line/paragraph separators)
|
|
//
|
|
// This is applied at the write boundary so content passes server validation.
|
|
func stripInputUnsafeChars(s string) string {
|
|
return strings.Map(func(r rune) rune {
|
|
// C0 controls (except tab and newline) and DEL
|
|
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) {
|
|
return -1
|
|
}
|
|
if isInputDangerousUnicode(r) {
|
|
return -1
|
|
}
|
|
return r
|
|
}, s)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// Shared JSONML preprocessing pipeline used by:
|
|
//
|
|
// dws doc create --content-format jsonml --content (body, root 校验)
|
|
// dws doc update --content-format jsonml --content (body, root 校验)
|
|
// dws doc block insert --content-format jsonml --element (single node, 不校验 root)
|
|
// dws doc block update --content-format jsonml --element (single node, 不校验 root)
|
|
//
|
|
// Pipeline:
|
|
//
|
|
// [optional JSON repair] → coerce → parse → validate → marshal
|
|
//
|
|
// Flags:
|
|
//
|
|
// --fix-jsonml 启用 JSON 语法 repair(括号/逗号补全),推荐 agent 调用时使用
|
|
// (缺省) 严格模式:仅做协议形状包装 + 校验,不做内容修复
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
// addJsonMLFlags registers --fix-jsonml on a cobra command.
|
|
// Idempotent — safe to call once per command.
|
|
func addJsonMLFlags(cmd *cobra.Command) {
|
|
if cmd.Flags().Lookup("fix-jsonml") == nil {
|
|
cmd.Flags().Bool("fix-jsonml", false,
|
|
"启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用")
|
|
}
|
|
}
|
|
|
|
// coerceJsonMLBodyShape normalizes the body input shape before parse.
|
|
//
|
|
// Accepted inputs:
|
|
// - {"jsonml": [...]} → passthrough (returned as-is)
|
|
// - [...] → wrapped to {"jsonml": [...]} silently (protocol-only coercion)
|
|
// - anything else → passthrough (let downstream parse emit the original error)
|
|
//
|
|
// Empty string is passthrough. The bare-array wrap is protocol plumbing
|
|
// invisible to callers; no fix note is emitted.
|
|
func coerceJsonMLBodyShape(raw string) (string, []string, error) {
|
|
if raw == "" {
|
|
return raw, nil, nil
|
|
}
|
|
var probe any
|
|
if err := json.Unmarshal([]byte(raw), &probe); err != nil {
|
|
return raw, nil, nil
|
|
}
|
|
if _, ok := probe.([]any); ok {
|
|
wrapped, _ := json.Marshal(map[string]any{"jsonml": probe})
|
|
return string(wrapped), nil, nil
|
|
}
|
|
return raw, nil, nil
|
|
}
|
|
|
|
// coerceJsonMLNodeShape is the block-command mirror of coerceJsonMLBodyShape.
|
|
//
|
|
// Accepted inputs:
|
|
// - [tag, attrs, ...] → passthrough
|
|
// - {"jsonml": [node]} → unwrap to bare node + fix note
|
|
// - {"jsonml": []} → error (empty wrapper)
|
|
// - {"jsonml": [n1, n2, ...]} → error (block ops accept exactly one node)
|
|
// - anything else → passthrough (downstream emits original error)
|
|
func coerceJsonMLNodeShape(raw string) (string, []string, error) {
|
|
if raw == "" {
|
|
return raw, nil, nil
|
|
}
|
|
var probe any
|
|
if err := json.Unmarshal([]byte(raw), &probe); err != nil {
|
|
return raw, nil, nil
|
|
}
|
|
if _, ok := probe.([]any); ok {
|
|
return raw, nil, nil
|
|
}
|
|
wrapper, ok := probe.(map[string]any)
|
|
if !ok {
|
|
return raw, nil, nil
|
|
}
|
|
inner, hasKey := wrapper["jsonml"]
|
|
if !hasKey {
|
|
return raw, nil, nil
|
|
}
|
|
arr, ok := inner.([]any)
|
|
if !ok {
|
|
return raw, nil, nil
|
|
}
|
|
switch len(arr) {
|
|
case 0:
|
|
return "", nil, fmt.Errorf(`--content-format jsonml 输入 {"jsonml":[]}: wrapper 中 jsonml 数组为空`)
|
|
case 1:
|
|
out, _ := json.Marshal(arr[0])
|
|
note := `输入为 {"jsonml":[node]} body 形态,已自动解包为单节点以符合 block 命令协议`
|
|
return string(out), []string{note}, nil
|
|
default:
|
|
return "", nil, fmt.Errorf(`block insert/update 一次只能处理一个 JSONML 节点,输入 {"jsonml":[...]} 包含 %d 个节点。请分多次调用,或使用 doc update --content-format jsonml 整篇覆盖`, len(arr))
|
|
}
|
|
}
|
|
|
|
// prepareJsonMLBody processes the body extracted from a `doc create/update
|
|
// --content` payload of shape `{"jsonml": [...]}`.
|
|
//
|
|
// Pipeline: [JSON repair] → coerce → parse → root 校验 → validate → marshal.
|
|
//
|
|
// Root 校验:doc create/update 要求 body 以 ["root", {attrs?}, ...] 形态传入。
|
|
// 缺少 root 报错而非自动包装——调用者应显式提供完整结构。
|
|
// block insert/update 走 prepareJsonMLNode,不做 root 校验。
|
|
//
|
|
// Returns the JSON-stringified body to be sent to the server.
|
|
func prepareJsonMLBody(cmd *cobra.Command, raw string) (string, error) {
|
|
fixJSON, _ := cmd.Flags().GetBool("fix-jsonml")
|
|
|
|
// Preserve original source for position-accurate schema diagnostics.
|
|
originalSrc := raw
|
|
repaired := false
|
|
|
|
// Step 1: JSON repair — fix broken syntax BEFORE any shape detection.
|
|
// jsonrepair only fixes brackets/commas; it does not alter semantics.
|
|
if fixJSON {
|
|
var probe any
|
|
if err := json.Unmarshal([]byte(raw), &probe); err != nil {
|
|
result, repairErr := repairJSONML(raw)
|
|
if repairErr != nil {
|
|
return "", fmt.Errorf("JSON 语法错误且自动修复失败: %w\n原始错误: %v", repairErr, err)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[FIX] JSON 语法已自动修复(括号/逗号等结构性错误)\n")
|
|
raw = result
|
|
repaired = true
|
|
}
|
|
}
|
|
|
|
// Step 2: Coerce shape — bare array → {"jsonml": [...]} wrapper.
|
|
coerced, coerceNotes, _ := coerceJsonMLBodyShape(raw)
|
|
emitFixNotes(coerceNotes)
|
|
raw = coerced
|
|
|
|
// Step 3: Parse into wrapper map.
|
|
var wrapper map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &wrapper); err != nil {
|
|
if !fixJSON {
|
|
// Use parser for detailed positional diagnostics
|
|
detail := diagnoseJSONMLError([]byte(raw))
|
|
if detail != "" {
|
|
return "", fmt.Errorf("JSON 语法错误: %w\n\n%s\n如果输入来自 LLM 生成,可通过 --fix-jsonml 尝试自动修复", err, detail)
|
|
}
|
|
return "", fmt.Errorf("JSON 语法错误: %w\n输入不是有效的 JSON(可能缺少括号或逗号)。如果输入来自 LLM 生成,可通过 --fix-jsonml 尝试自动修复", err)
|
|
}
|
|
// fixJSON was true but repair+coerce still couldn't produce a valid wrapper
|
|
return "", fmt.Errorf("JSON 修复后仍无法解析为 {\"jsonml\": [...]}: %w", err)
|
|
}
|
|
|
|
bodyAny, ok := wrapper["jsonml"]
|
|
if !ok {
|
|
return "", fmt.Errorf(`--content-format jsonml 输入 JSON 必须包含 "jsonml" 字段,格式: {"jsonml": [...]}`)
|
|
}
|
|
bodyArr, ok := bodyAny.([]any)
|
|
if !ok {
|
|
return "", fmt.Errorf(`--content-format jsonml 字段 "jsonml" 必须是数组`)
|
|
}
|
|
|
|
// A scoped read returns a read-only fragment container. Strip that
|
|
// container before root/schema validation so users can feed the selected
|
|
// nodes into create/update without writing the synthetic fragment tag.
|
|
bodyArr, fragmentCount := stripBodyFragments(bodyArr)
|
|
if fragmentCount > 0 {
|
|
fmt.Fprintf(
|
|
os.Stderr,
|
|
"[WARN] 已自动移除 %d 处 fragment 只读容器外层(fragment 是 doc read --scope/--tags 的查询结果,不能写回文档),保留其子节点。请确认这是你想写入的内容。\n",
|
|
fragmentCount,
|
|
)
|
|
}
|
|
|
|
// Root 校验:doc create/update 要求以 ["root", {attrs?}, ...] 为根。
|
|
if len(bodyArr) == 0 {
|
|
return "", fmt.Errorf(`--content-format jsonml body 为空数组,期望 ["root", {attrs?}, ...blocks]`)
|
|
}
|
|
if tag, ok := bodyArr[0].(string); !ok || tag != "root" {
|
|
return "", fmt.Errorf(`--content-format jsonml body 必须以 "root" 为根节点,格式: ["root", {attrs?}, ...blocks]。当前首元素: %v`, bodyArr[0])
|
|
}
|
|
|
|
// Validate: always ON.
|
|
// Use ValidateJsonMLSource with original bytes for accurate line/col positions
|
|
// when the original input is a bare JSONML array AND was not repaired.
|
|
// If repair happened, original source is broken — validate parsed data instead.
|
|
var vr *doc.JsonMLValidationResult
|
|
if !repaired && fragmentCount == 0 && strings.HasPrefix(strings.TrimSpace(originalSrc), "[") {
|
|
vr = doc.ValidateJsonMLSource([]byte(originalSrc))
|
|
} else {
|
|
vr = doc.ValidateJsonMLBodyV2(bodyArr)
|
|
}
|
|
if vr.HasErrors() {
|
|
return "", fmt.Errorf("JSONML 格式校验失败:\n%s",
|
|
vr.Summary())
|
|
}
|
|
if summary := vr.Summary(); summary != "" {
|
|
fmt.Fprintf(os.Stderr, "[WARN] %s\n", summary)
|
|
}
|
|
|
|
out, _ := json.Marshal(bodyArr)
|
|
cleaned := stripInputUnsafeChars(string(out))
|
|
return cleaned, nil
|
|
}
|
|
|
|
// prepareJsonMLNode processes the JSONML array passed to `doc block
|
|
// insert/update --element` (a single JSONML block node, not a body).
|
|
//
|
|
// Pipeline: [JSON repair] → coerce → parse → validate → marshal.
|
|
// No root 校验——block 级操作不要求 root 根节点。
|
|
func prepareJsonMLNode(cmd *cobra.Command, rawElement string) (string, error) {
|
|
if rawElement == "" {
|
|
return "", fmt.Errorf("--content-format jsonml 要求通过 --element 提供 JSONML 数组")
|
|
}
|
|
fixJSON, _ := cmd.Flags().GetBool("fix-jsonml")
|
|
|
|
// Preserve original source for position-accurate schema diagnostics.
|
|
originalSrc := rawElement
|
|
repaired := false
|
|
|
|
// Step 1: JSON repair — fix broken syntax BEFORE shape detection.
|
|
if fixJSON {
|
|
var probe any
|
|
if err := json.Unmarshal([]byte(rawElement), &probe); err != nil {
|
|
result, repairErr := repairJSONML(rawElement)
|
|
if repairErr != nil {
|
|
return "", fmt.Errorf("JSON 语法错误且自动修复失败: %w\n原始错误: %v", repairErr, err)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[FIX] JSON 语法已自动修复(括号/逗号等结构性错误)\n")
|
|
rawElement = result
|
|
repaired = true
|
|
}
|
|
}
|
|
|
|
// Step 2: Coerce shape — unwrap {"jsonml":[node]} → bare node.
|
|
coerced, coerceNotes, err := coerceJsonMLNodeShape(rawElement)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
emitFixNotes(coerceNotes)
|
|
rawElement = coerced
|
|
|
|
// Step 3: Parse.
|
|
var node any
|
|
if err := json.Unmarshal([]byte(rawElement), &node); err != nil {
|
|
if !fixJSON {
|
|
// Use parser for detailed positional diagnostics
|
|
detail := diagnoseJSONMLError([]byte(rawElement))
|
|
if detail != "" {
|
|
return "", fmt.Errorf("JSON 语法错误: %w\n\n%s\n如果输入来自 LLM 生成,可通过 --fix-jsonml 尝试自动修复", err, detail)
|
|
}
|
|
return "", fmt.Errorf("JSON 语法错误: %w\n输入不是有效的 JSON(可能缺少括号或逗号)。如果输入来自 LLM 生成,可通过 --fix-jsonml 尝试自动修复", err)
|
|
}
|
|
return "", fmt.Errorf("JSON 语法错误: %w", err)
|
|
}
|
|
nodeArr, ok := node.([]any)
|
|
if !ok {
|
|
return "", fmt.Errorf("--content-format jsonml 要求 --element 为 JSON 数组,实际类型: %T", node)
|
|
}
|
|
|
|
// Block writes accept one node. Unwrap a scoped-read fragment only when it
|
|
// resolves to exactly one node; nested fragments inside a regular node are
|
|
// spliced recursively.
|
|
strippedNode, fragmentCount, err := stripNodeFragments(nodeArr)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if fragmentCount > 0 {
|
|
fmt.Fprintf(
|
|
os.Stderr,
|
|
"[WARN] 已自动移除 %d 处 fragment 只读容器外层(fragment 是 doc read --scope/--tags 的查询结果,不能写回文档),保留其子节点。\n",
|
|
fragmentCount,
|
|
)
|
|
}
|
|
node = strippedNode
|
|
|
|
// Validate: always ON.
|
|
// Use ValidateJsonMLSource with original bytes for accurate line/col positions
|
|
// when the original input is a bare JSONML node array AND was not repaired.
|
|
var vr *doc.JsonMLValidationResult
|
|
if !repaired && fragmentCount == 0 && strings.HasPrefix(strings.TrimSpace(originalSrc), "[") {
|
|
vr = doc.ValidateJsonMLSource([]byte(originalSrc))
|
|
} else {
|
|
vr = doc.ValidateJsonMLNodeV2(node)
|
|
}
|
|
if vr.HasErrors() {
|
|
return "", fmt.Errorf("JSONML 格式校验失败:\n%s",
|
|
vr.Summary())
|
|
}
|
|
if summary := vr.Summary(); summary != "" {
|
|
fmt.Fprintf(os.Stderr, "[WARN] %s\n", summary)
|
|
}
|
|
|
|
out, _ := json.Marshal(node)
|
|
return stripInputUnsafeChars(string(out)), nil
|
|
}
|
|
|
|
func emitFixNotes(notes []string) {
|
|
if len(notes) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[FIX] JSONML 自动修复(%d 项):\n", len(notes))
|
|
for i, n := range notes {
|
|
fmt.Fprintf(os.Stderr, " %d. %s\n", i+1, n)
|
|
}
|
|
}
|