mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
a6f3e635d4
* feat(docs): add local authoring and resource workflows Add docs +script workflows for isolated draft initialization and tolerant XML/Markdown profiling. Support local and remote document resources across create and update flows with safe, bounded-concurrency uploads, binding verification, and cleanup. Synchronize shared credential-source selection during concurrent uploads, refresh lark-doc guidance, and expand unit, dry-run, and live E2E coverage. * docs(lark-doc): clarify genre reference paths * fix(docs): address PR validation feedback * docs(lark-doc): clarify remote image handling * feat: streamline docs draft workflow * fix(docs): clarify script input and resource cleanup * fix(docs): align script dry-run test with auth flow * fix(docs): authenticate local script e2e test * fix(docs): refine script diagnostics and image preflight * fix(docs): align draft workspace cleanup with VFS * fix(docs): route workspace cleanup through FileIO * docs(lark-doc): simplify profile check guidance
896 lines
35 KiB
Go
896 lines
35 KiB
Go
// 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 '<JSON>'
|
|
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 <img path=\"@relative/path\"/>."
|
|
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 <img path=\"@relative/path\"/>."
|
|
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 <img path=\"@relative/path\"/>."
|
|
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 <img path=\"@relative/path\"/>."
|
|
}
|
|
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
|
|
}
|