mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 18:13:01 +08:00
Compare commits
1 Commits
feat/affor
...
feat/drive
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faa7a92217 |
@@ -39,7 +39,7 @@ func wrapExportContextErr(err error) error {
|
||||
var DriveExport = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+export",
|
||||
Description: "Export a doc/docx/sheet/bitable/slides to a local file with limited polling",
|
||||
Description: "Export a doc/docx/sheet/bitable/slides or wiki document to a local file with limited polling",
|
||||
Risk: "read",
|
||||
Scopes: []string{
|
||||
"docs:document.content:read",
|
||||
@@ -47,10 +47,12 @@ var DriveExport = common.Shortcut{
|
||||
"docx:document:readonly",
|
||||
"drive:drive.metadata:readonly",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "source document token", Required: true},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}},
|
||||
{Name: "url", Desc: "source document URL; doc type and token are inferred, and wiki URLs are resolved to the underlying document"},
|
||||
{Name: "token", Desc: "source document token; bare tokens require --doc-type, while wiki tokens can use --doc-type wiki or fallback after file-token-invalid"},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides | wiki (required only when --token is a bare token)", Enum: []string{"doc", "docx", "sheet", "bitable", "slides", "wiki"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
|
||||
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
|
||||
{Name: "only-schema", Type: "bool", Desc: "export only bitable schema when --doc-type bitable --file-extension base"},
|
||||
@@ -75,6 +77,7 @@ var DriveExport = common.Shortcut{
|
||||
// task and poll, but do not download" — callers that only need the ready file
|
||||
// token / status get it back without writing a local file.
|
||||
type ExportParams struct {
|
||||
URL string
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
@@ -87,6 +90,7 @@ type ExportParams struct {
|
||||
|
||||
func (p ExportParams) spec() driveExportSpec {
|
||||
return driveExportSpec{
|
||||
URL: p.URL,
|
||||
Token: p.Token,
|
||||
DocType: p.DocType,
|
||||
FileExtension: p.FileExtension,
|
||||
@@ -106,6 +110,7 @@ func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
outputDir = "."
|
||||
}
|
||||
return ExportParams{
|
||||
URL: runtime.Str("url"),
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
@@ -127,60 +132,93 @@ func validateExport(p ExportParams) error {
|
||||
|
||||
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
|
||||
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
spec, source, err := normalizeDriveExportSpecInput(p.spec())
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
if source.Type == "wiki" {
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[0] Resolve wiki node to underlying document token").
|
||||
Params(map[string]interface{}{"token": source.Token})
|
||||
spec.Token = "obj_token_from_step_0"
|
||||
if spec.DocType == "" {
|
||||
spec.DocType = "obj_type_from_step_0"
|
||||
}
|
||||
dry.Set("wiki_token", source.Token)
|
||||
}
|
||||
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
desc := "2-step orchestration: fetch docx markdown -> write local file"
|
||||
if source.Type == "wiki" {
|
||||
desc = "3-step orchestration: resolve wiki -> fetch docx markdown -> write local file"
|
||||
}
|
||||
dry.Desc(desc).
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
return dry
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
desc := "3-step orchestration: create export task -> limited polling -> download file"
|
||||
if source.Type == "wiki" {
|
||||
desc = "4-step orchestration: resolve wiki -> create export task -> limited polling -> download file"
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
dry.Desc(desc).
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Body(buildDriveExportTaskBody(spec)).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
if !source.WasURL {
|
||||
dry.Set("wiki_token_fallback", "if export task returns file token invalid, the CLI will resolve --token as a wiki node and retry once")
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
// RunExport drives create export task -> bounded poll -> optional download. It
|
||||
// is the shared core behind both drive +export and sheets +workbook-export. An
|
||||
// empty p.OutputDir skips the download step and returns the ready file token.
|
||||
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
|
||||
spec := p.spec()
|
||||
spec, source, err := normalizeDriveExportSpecInput(p.spec())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outputDir := p.OutputDir
|
||||
preferredFileName := strings.TrimSpace(p.FileName)
|
||||
overwrite := p.Overwrite
|
||||
|
||||
var wikiResolution driveExportWikiResolution
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
if source.Type == "wiki" {
|
||||
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec = resolvedSpec
|
||||
wikiResolution = resolution
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
@@ -222,21 +260,23 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
runtime.Out(annotateDriveExportWikiOutput(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
}, wikiResolution), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
ticket, resolvedSpec, resolution, err := createDriveExportTaskWithWikiFallback(ctx, runtime, spec, source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec = resolvedSpec
|
||||
wikiResolution = resolution
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
@@ -274,7 +314,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
// no local download (e.g. sheets +workbook-export without an output
|
||||
// path). Skip the download and return the status envelope.
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
runtime.Out(annotateDriveExportWikiOutput(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
@@ -284,7 +324,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
"file_size": status.FileSize,
|
||||
"ready": true,
|
||||
"downloaded": false,
|
||||
}, nil)
|
||||
}, wikiResolution), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -307,7 +347,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
runtime.Out(annotateDriveExportWikiOutput(out, wikiResolution), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -357,7 +397,19 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
runtime.Out(annotateDriveExportWikiOutput(result, wikiResolution), nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
}
|
||||
|
||||
func annotateDriveExportWikiOutput(out map[string]interface{}, resolution driveExportWikiResolution) map[string]interface{} {
|
||||
if !resolution.Resolved {
|
||||
return out
|
||||
}
|
||||
out["wiki_token"] = resolution.WikiToken
|
||||
out["wiki_node"] = map[string]interface{}{
|
||||
"obj_token": resolution.ObjToken,
|
||||
"obj_type": resolution.ObjType,
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -27,9 +27,16 @@ var (
|
||||
driveExportPollInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
driveExportResolvedDocTypeValues = "doc, docx, sheet, bitable, slides"
|
||||
driveExportInputDocTypeValues = driveExportResolvedDocTypeValues + ", wiki"
|
||||
driveExportFileExtensionValues = "docx, pdf, xlsx, csv, markdown, base, pptx"
|
||||
)
|
||||
|
||||
// driveExportSpec contains the normalized export request understood by the
|
||||
// shortcut and the underlying export task APIs.
|
||||
type driveExportSpec struct {
|
||||
URL string
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
@@ -37,6 +44,20 @@ type driveExportSpec struct {
|
||||
OnlySchema bool
|
||||
}
|
||||
|
||||
type driveExportInputSource struct {
|
||||
Type string
|
||||
Token string
|
||||
Param string
|
||||
WasURL bool
|
||||
}
|
||||
|
||||
type driveExportWikiResolution struct {
|
||||
Resolved bool
|
||||
WikiToken string
|
||||
ObjToken string
|
||||
ObjType string
|
||||
}
|
||||
|
||||
// driveExportTaskResultCommand prints the resume command shown when bounded
|
||||
// export polling times out locally.
|
||||
func driveExportTaskResultCommand(ticket, docToken string) string {
|
||||
@@ -127,45 +148,49 @@ func (s driveExportStatus) StatusLabel() string {
|
||||
// validateDriveExportSpec enforces shortcut-level export constraints before any
|
||||
// backend request is sent.
|
||||
func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
normalized, source, err := normalizeDriveExportSpecInput(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDriveExportNormalizedSpecForSource(normalized, source)
|
||||
}
|
||||
|
||||
func validateDriveExportNormalizedSpec(spec driveExportSpec) error {
|
||||
switch spec.DocType {
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType).WithParam("--doc-type")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are %s", spec.DocType, driveExportInputDocTypeValues).
|
||||
WithParam("--doc-type").
|
||||
WithHint("use --url when you have a document URL; use --doc-type wiki only with a bare Wiki node token so the CLI can resolve the underlying document type")
|
||||
}
|
||||
|
||||
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension).WithParam("--file-extension")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
|
||||
WithParam("--file-extension").
|
||||
WithHint("choose an export format supported by the source type; common choices are docx/pdf for docs, xlsx/csv for sheets, base for bitable, and pptx/pdf for slides")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension markdown only supports --doc-type docx")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "base" && spec.DocType != "bitable" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable")
|
||||
if err := validateDriveExportFormatCompatibility(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.OnlySchema && (spec.DocType != "bitable" || spec.FileExtension != "base") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").WithParam("--only-schema")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides")
|
||||
}
|
||||
|
||||
if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type slides only supports --file-extension pptx or pdf")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
|
||||
WithParam("--only-schema").
|
||||
WithHint("retry with --doc-type bitable --file-extension base, or remove --only-schema")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").WithParam("--sub-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("remove --sub-id, or retry with --doc-type sheet|bitable --file-extension csv")
|
||||
}
|
||||
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
|
||||
@@ -173,15 +198,213 @@ func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
}
|
||||
|
||||
if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").WithParam("--sub-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("retry with --sub-id <sheet_id_or_table_id>; if you need the whole workbook, use --file-extension xlsx instead")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDriveExportTask starts the asynchronous export job and returns its
|
||||
// ticket for subsequent polling.
|
||||
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
|
||||
func validateDriveExportFormatCompatibility(spec driveExportSpec) error {
|
||||
if driveExportFileExtensionAllowedForDocType(spec.DocType, spec.FileExtension) {
|
||||
return nil
|
||||
}
|
||||
allowed := strings.Join(driveExportAllowedFileExtensions(spec.DocType), ", ")
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported export format: --doc-type %s cannot be exported as %s",
|
||||
spec.DocType,
|
||||
spec.FileExtension,
|
||||
).
|
||||
WithParam("--file-extension").
|
||||
WithHint("retry with --file-extension %s. If the token came from a URL, prefer --url so the CLI infers the correct source type before validating the export format", allowed)
|
||||
}
|
||||
|
||||
func driveExportFileExtensionAllowedForDocType(docType, fileExtension string) bool {
|
||||
for _, allowed := range driveExportAllowedFileExtensions(docType) {
|
||||
if fileExtension == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveExportAllowedFileExtensions(docType string) []string {
|
||||
switch normalizeDriveExportDocType(docType) {
|
||||
case "doc":
|
||||
return []string{"docx", "pdf"}
|
||||
case "docx":
|
||||
return []string{"docx", "pdf", "markdown"}
|
||||
case "sheet":
|
||||
return []string{"xlsx", "csv", "pdf"}
|
||||
case "bitable":
|
||||
return []string{"xlsx", "csv", "base", "pdf"}
|
||||
case "slides":
|
||||
return []string{"pptx", "pdf"}
|
||||
default:
|
||||
return []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveExportNormalizedSpecForSource(spec driveExportSpec, source driveExportInputSource) error {
|
||||
if source.Type == "wiki" && spec.DocType == "" {
|
||||
return validateDriveExportPendingWikiSpec(spec, source)
|
||||
}
|
||||
return validateDriveExportNormalizedSpec(spec)
|
||||
}
|
||||
|
||||
func validateDriveExportPendingWikiSpec(spec driveExportSpec, source driveExportInputSource) error {
|
||||
param := source.Param
|
||||
if param == "" {
|
||||
param = "--token"
|
||||
}
|
||||
if err := validate.ResourceName(spec.Token, param); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(param)
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
|
||||
WithParam("--file-extension").
|
||||
WithHint("Wiki export format is validated after resolving the Wiki node; choose a format normally supported by the underlying document type")
|
||||
}
|
||||
if spec.OnlySchema && spec.FileExtension != "base" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
|
||||
WithParam("--only-schema").
|
||||
WithHint("retry with --file-extension base, or remove --only-schema")
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" && spec.FileExtension != "csv" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("remove --sub-id, or retry with --file-extension csv if the Wiki node resolves to a sheet/bitable")
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDriveExportSpecInput(spec driveExportSpec) (driveExportSpec, driveExportInputSource, error) {
|
||||
spec.URL = strings.TrimSpace(spec.URL)
|
||||
spec.Token = strings.TrimSpace(spec.Token)
|
||||
spec.DocType = strings.ToLower(strings.TrimSpace(spec.DocType))
|
||||
spec.FileExtension = strings.ToLower(strings.TrimSpace(spec.FileExtension))
|
||||
|
||||
if spec.Token == "" && spec.URL == "" {
|
||||
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "either --url or --token is required").WithParam("--url")
|
||||
}
|
||||
if spec.Token != "" && spec.URL != "" {
|
||||
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive").WithParam("--url")
|
||||
}
|
||||
|
||||
source := driveExportInputSource{
|
||||
Type: spec.DocType,
|
||||
Token: spec.Token,
|
||||
Param: "--token",
|
||||
}
|
||||
|
||||
rawInput := spec.Token
|
||||
inputParam := "--token"
|
||||
if spec.URL != "" {
|
||||
rawInput = spec.URL
|
||||
inputParam = "--url"
|
||||
}
|
||||
|
||||
if ref, ok := common.ParseResourceURL(rawInput); ok {
|
||||
refType := normalizeDriveExportDocType(ref.Type)
|
||||
source = driveExportInputSource{
|
||||
Type: refType,
|
||||
Token: ref.Token,
|
||||
Param: inputParam,
|
||||
WasURL: true,
|
||||
}
|
||||
spec.Token = ref.Token
|
||||
if refType != "wiki" {
|
||||
if !isDriveExportDocType(refType) {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"%s URL type %q is not supported by drive +export; use a doc/docx/sheet/base/slides/wiki URL or token",
|
||||
inputParam,
|
||||
ref.Type,
|
||||
).WithParam(inputParam)
|
||||
}
|
||||
if spec.DocType == "wiki" {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--doc-type wiki conflicts with %s URL type %q",
|
||||
inputParam,
|
||||
refType,
|
||||
).
|
||||
WithParam("--doc-type").
|
||||
WithHint("remove --doc-type when passing --url; the CLI will infer %q from the URL", refType)
|
||||
}
|
||||
if spec.DocType != "" && spec.DocType != refType {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--doc-type %q conflicts with %s URL type %q",
|
||||
spec.DocType,
|
||||
inputParam,
|
||||
refType,
|
||||
).WithParam("--doc-type")
|
||||
}
|
||||
spec.DocType = refType
|
||||
} else if spec.DocType == "wiki" {
|
||||
spec.DocType = ""
|
||||
}
|
||||
return spec, source, nil
|
||||
}
|
||||
|
||||
if strings.Contains(rawInput, "://") {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported %s URL %q: use a recognized Lark document URL",
|
||||
inputParam,
|
||||
rawInput,
|
||||
).WithParam(inputParam)
|
||||
}
|
||||
if spec.URL != "" {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --url %q: use a recognized Lark document URL",
|
||||
spec.URL,
|
||||
).WithParam("--url")
|
||||
}
|
||||
if spec.DocType == "" {
|
||||
return spec, source, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type is required when --token is a bare token (allowed: %s)", driveExportInputDocTypeValues).
|
||||
WithParam("--doc-type").
|
||||
WithHint("if you have the original document link, prefer --url <document_url>; if this is a Wiki node token, use --doc-type wiki")
|
||||
}
|
||||
if spec.DocType == "wiki" {
|
||||
source.Type = "wiki"
|
||||
source.Token = spec.Token
|
||||
spec.DocType = ""
|
||||
}
|
||||
return spec, source, nil
|
||||
}
|
||||
|
||||
func normalizeDriveExportDocType(docType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(docType)) {
|
||||
case "base":
|
||||
return "bitable"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(docType))
|
||||
}
|
||||
}
|
||||
|
||||
func isDriveExportDocType(docType string) bool {
|
||||
switch normalizeDriveExportDocType(docType) {
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveExportTaskBody(spec driveExportSpec) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
@@ -193,8 +416,13 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
// createDriveExportTask starts the asynchronous export job and returns its
|
||||
// ticket for subsequent polling.
|
||||
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, buildDriveExportTaskBody(spec))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -206,6 +434,99 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
func resolveDriveExportWikiSource(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, wikiToken string) (driveExportSpec, driveExportWikiResolution, error) {
|
||||
wikiToken = strings.TrimSpace(wikiToken)
|
||||
if err := validate.ResourceName(wikiToken, "--token"); err != nil {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node for export: %s\n", common.MaskToken(wikiToken))
|
||||
data, err := driveInspectCallWithRetry(ctx, func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": wikiToken},
|
||||
nil,
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
return spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
|
||||
node := common.GetMap(data, "node")
|
||||
objType := normalizeDriveExportDocType(common.GetString(node, "obj_type"))
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
if objType == "" || objToken == "" {
|
||||
return spec, driveExportWikiResolution{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
|
||||
}
|
||||
if !isDriveExportDocType(objType) {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but drive +export only supports doc, docx, sheet, bitable, and slides",
|
||||
objType,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if spec.DocType != "" && spec.DocType != objType {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but --doc-type is %q; use --doc-type %s",
|
||||
objType,
|
||||
spec.DocType,
|
||||
objType,
|
||||
).WithParam("--doc-type")
|
||||
}
|
||||
|
||||
spec.Token = objToken
|
||||
spec.DocType = objType
|
||||
if err := validateDriveExportNormalizedSpec(spec); err != nil {
|
||||
return spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return spec, driveExportWikiResolution{
|
||||
Resolved: true,
|
||||
WikiToken: wikiToken,
|
||||
ObjToken: objToken,
|
||||
ObjType: objType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createDriveExportTaskWithWikiFallback(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, source driveExportInputSource) (string, driveExportSpec, driveExportWikiResolution, error) {
|
||||
if source.Type == "wiki" {
|
||||
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
|
||||
if err != nil {
|
||||
return "", spec, resolution, err
|
||||
}
|
||||
ticket, err := createDriveExportTask(runtime, resolvedSpec)
|
||||
return ticket, resolvedSpec, resolution, err
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err == nil {
|
||||
return ticket, spec, driveExportWikiResolution{}, nil
|
||||
}
|
||||
if source.WasURL || !shouldRetryDriveExportAsWiki(err) {
|
||||
return "", spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
|
||||
resolvedSpec, resolution, resolveErr := resolveDriveExportWikiSource(ctx, runtime, spec, spec.Token)
|
||||
if resolveErr != nil {
|
||||
return "", spec, driveExportWikiResolution{}, appendDriveExportRecoveryHint(
|
||||
err,
|
||||
fmt.Sprintf("export task rejected --token; attempted wiki node resolution also failed: %v", resolveErr),
|
||||
)
|
||||
}
|
||||
ticket, retryErr := createDriveExportTask(runtime, resolvedSpec)
|
||||
return ticket, resolvedSpec, resolution, retryErr
|
||||
}
|
||||
|
||||
func shouldRetryDriveExportAsWiki(err error) bool {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem == nil {
|
||||
return false
|
||||
}
|
||||
return problem.Code == 1069914
|
||||
}
|
||||
|
||||
// getDriveExportStatus fetches the current backend state for a previously
|
||||
// created export task.
|
||||
func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) {
|
||||
|
||||
@@ -33,10 +33,36 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
name: "markdown docx ok",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "markdown"},
|
||||
},
|
||||
{
|
||||
name: "docx url infers doc type",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/docx/docxURL123", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki url can defer doc type until resolution",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki url with doc-type wiki can defer doc type until resolution",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", DocType: "wiki", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki token with doc-type wiki can defer doc type until resolution",
|
||||
spec: driveExportSpec{Token: "wiki123", DocType: "wiki", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "bare token requires doc type",
|
||||
spec: driveExportSpec{Token: "docx123", FileExtension: "pdf"},
|
||||
wantErr: "--doc-type is required",
|
||||
},
|
||||
{
|
||||
name: "markdown non docx rejected",
|
||||
spec: driveExportSpec{Token: "doc123", DocType: "doc", FileExtension: "markdown"},
|
||||
wantErr: "only supports --doc-type docx",
|
||||
wantErr: "cannot be exported as markdown",
|
||||
},
|
||||
{
|
||||
name: "docx csv rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "csv"},
|
||||
wantErr: "cannot be exported as csv",
|
||||
},
|
||||
{
|
||||
name: "csv without sub id rejected",
|
||||
@@ -72,17 +98,17 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
{
|
||||
name: "base non bitable rejected",
|
||||
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "base"},
|
||||
wantErr: "only supports --doc-type bitable",
|
||||
wantErr: "cannot be exported as base",
|
||||
},
|
||||
{
|
||||
name: "pptx non slides rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pptx"},
|
||||
wantErr: "only supports --doc-type slides",
|
||||
wantErr: "cannot be exported as pptx",
|
||||
},
|
||||
{
|
||||
name: "slides csv rejected",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "csv"},
|
||||
wantErr: "slides only supports",
|
||||
wantErr: "cannot be exported as csv",
|
||||
},
|
||||
{
|
||||
name: "unknown doc type rejected",
|
||||
@@ -113,6 +139,29 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveExportUnsupportedFormatHasHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveExportSpec(driveExportSpec{
|
||||
Token: "docx123",
|
||||
DocType: "docx",
|
||||
FileExtension: "csv",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported format error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if valErr.Param != "--file-extension" {
|
||||
t.Fatalf("param = %q, want --file-extension", valErr.Param)
|
||||
}
|
||||
if !strings.Contains(valErr.Hint, "docx, pdf, markdown") || !strings.Contains(valErr.Hint, "--url") {
|
||||
t.Fatalf("hint = %q, want allowed formats and URL retry guidance", valErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
fetchStub := &httpmock.Stub{
|
||||
@@ -440,6 +489,76 @@ func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportURLInfersDocType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_url"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_url",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_url",
|
||||
"file_name": "url-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_url/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="url-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--url", "https://example.feishu.cn/docx/docxURL123",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxURL123" {
|
||||
t.Fatalf("export_tasks body token = %v, want token from URL", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want inferred docx", createBody["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -510,6 +629,318 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportWikiURLResolvesBeforeAsyncTask(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_wiki"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_wiki",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_wiki",
|
||||
"file_name": "wiki-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="wiki-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--url", "https://example.feishu.cn/wiki/wikiNode123",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxResolved" {
|
||||
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want docx", createBody["type"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNode123"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportBareWikiTypeResolvesBeforeAsyncTask(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_wiki_token"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_wiki_token",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_wiki_token",
|
||||
"file_name": "wiki-token-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki_token/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="wiki-token-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "wikiNodeBare",
|
||||
"--doc-type", "wiki",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxResolved" {
|
||||
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want resolved docx type", createBody["type"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNodeBare"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportBareWikiTokenFallbackAfterFileTokenInvalid(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
firstCreate := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Status: 404,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069914,
|
||||
"msg": "file token invalid",
|
||||
"log_id": "20260708000000TEST",
|
||||
},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"token":"wikiNodeBare"`)
|
||||
},
|
||||
}
|
||||
reg.Register(firstCreate)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
retryCreate := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_retry"},
|
||||
},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"token":"docxResolved"`)
|
||||
},
|
||||
}
|
||||
reg.Register(retryCreate)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_retry",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_retry",
|
||||
"file_name": "retry-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_retry/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="retry-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "wikiNodeBare",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(firstCreate.CapturedBody) == 0 {
|
||||
t.Fatal("first export task request was not sent with the original token")
|
||||
}
|
||||
var retryBody map[string]interface{}
|
||||
if err := json.Unmarshal(retryCreate.CapturedBody, &retryBody); err != nil {
|
||||
t.Fatalf("unmarshal retry export_tasks body: %v", err)
|
||||
}
|
||||
if retryBody["token"] != "docxResolved" {
|
||||
t.Fatalf("retry export_tasks body token = %v, want resolved docx token", retryBody["token"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNodeBare"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportWikiResolvedTypeMismatch(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "https://example.feishu.cn/wiki/wikiSheet123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected type mismatch error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(valErr.Message, `wiki resolved to "sheet"`) {
|
||||
t.Fatalf("error message = %q, want resolved type", valErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
|
||||
// explicit empty --output-dir must still download to the current directory
|
||||
// (normalized to "."), not trigger the export-only no-download path that the
|
||||
|
||||
@@ -37,6 +37,7 @@ metadata:
|
||||
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`。
|
||||
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
|
||||
- 用户要导出云文档时,优先使用 `lark-cli drive +export --url '<文档 URL>' --file-extension <格式>`;URL 会自动解析类型和 token,Wiki URL 会自动解析到底层 `obj_token/obj_type`。只有手里只有裸 token 时才使用 `--token <TOKEN> --doc-type <doc|docx|sheet|bitable|slides|wiki>`;裸 Wiki token 推荐显式传 `--doc-type wiki`,CLI 会先解析 Wiki node;如果误按底层类型传参且 export task 返回 `file token invalid`,CLI 也会尝试按 Wiki node token 解析并重试一次。
|
||||
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
- 用户给的是 wiki URL / token,且后续还没明确底层资源类型时,先用 `lark-cli drive +inspect` 解包;`+inspect` 失败后不要自动切到别的写接口继续尝试,先按错误提示处理权限、scope 或链接问题。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
把 `doc` / `docx` / `sheet` / `bitable` / `slides` 导出到本地文件。这个 shortcut 内置有限轮询:
|
||||
把 `doc` / `docx` / `sheet` / `bitable` / `slides`(也支持 Wiki URL / Wiki node token 自动解包)导出到本地文件。这个 shortcut 内置有限轮询:
|
||||
|
||||
- 如果导出任务在轮询窗口内完成,会直接下载到本地目录
|
||||
- 如果轮询结束仍未完成,会返回 `ticket`、`ready=false`、`timed_out=true` 和 `next_command`
|
||||
@@ -13,6 +13,29 @@
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 推荐:直接传 URL,CLI 自动解析类型和 token
|
||||
lark-cli drive +export \
|
||||
--url "https://example.feishu.cn/docx/<DOCX_TOKEN>" \
|
||||
--file-extension pdf
|
||||
|
||||
# Wiki URL 也推荐直接传,CLI 会先解析到底层 obj_token/obj_type
|
||||
lark-cli drive +export \
|
||||
--url "https://example.feishu.cn/wiki/<WIKI_NODE_TOKEN>" \
|
||||
--file-extension pdf
|
||||
|
||||
# 只有裸 Wiki node token 时,显式传 --doc-type wiki,让 CLI 先解析到底层文档类型
|
||||
lark-cli drive +export \
|
||||
--token "<WIKI_NODE_TOKEN>" \
|
||||
--doc-type wiki \
|
||||
--file-extension pdf
|
||||
|
||||
# 兼容兜底:如果误把 Wiki token 搭配底层类型传入,且 export task 返回 file token invalid,
|
||||
# CLI 会尝试按 Wiki node token 解析并重试一次;但新脚本仍推荐上面的 --doc-type wiki
|
||||
lark-cli drive +export \
|
||||
--token "<WIKI_NODE_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf
|
||||
|
||||
# 导出新版文档为 pdf,默认保存到当前目录
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
@@ -96,8 +119,9 @@ lark-cli drive +export \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | 源文档 token |
|
||||
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` |
|
||||
| `--url` | 与 `--token` 二选一 | 源文档 URL,推荐优先使用;CLI 自动解析类型和 token,Wiki URL 会解析到底层 `obj_token/obj_type` |
|
||||
| `--token` | 与 `--url` 二选一 | 源文档裸 token;裸 token 必须同时传 `--doc-type`。裸 Wiki node token 推荐传 `--doc-type wiki`,CLI 会先解析到底层 `obj_token/obj_type`;如误传底层类型并触发 export task `file token invalid`,CLI 会尝试按 Wiki node token 解析并重试一次 |
|
||||
| `--doc-type` | 条件必填 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` / `wiki`;仅当使用裸 `--token` 时必填,使用 `--url` 时自动推断。`wiki` 只用于裸 Wiki node token,解析后会按真实底层类型发起导出 |
|
||||
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` / `base` / `pptx` |
|
||||
| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 |
|
||||
| `--only-schema` | 否 | 仅当 `--doc-type bitable --file-extension base` 时可用;只导出多维表格结构,不导出记录数据 |
|
||||
@@ -107,12 +131,17 @@ lark-cli drive +export \
|
||||
|
||||
## 关键约束
|
||||
|
||||
- `markdown` 只支持 `docx`
|
||||
- `base` 只支持 `bitable`
|
||||
- `--only-schema` 只支持 `bitable` 导出为 `.base`,用于仅导出表结构
|
||||
- `pptx` 只支持 `slides`
|
||||
- 推荐优先传 `--url`,不要从 URL 手工拆 token 和 type;尤其是 Wiki URL,CLI 会自动解包到底层资源
|
||||
- `--url` 和 `--token` 互斥
|
||||
- 裸 `--token` 必须传 `--doc-type`;裸 Wiki node token 使用 `--doc-type wiki`
|
||||
- `doc` 支持导出为 `docx` / `pdf`
|
||||
- `docx` 支持导出为 `docx` / `pdf` / `markdown`
|
||||
- `sheet` 支持导出为 `xlsx` / `csv` / `pdf`
|
||||
- `bitable` 支持导出为 `xlsx` / `csv` / `base` / `pdf`
|
||||
- `slides` 支持导出为 `pptx` / `pdf`
|
||||
- `sheet` / `bitable` 导出为 `csv` 时必须带 `--sub-id`
|
||||
- `csv` 只支持 `sheet` / `bitable`,且必须带 `--sub-id`
|
||||
- `--only-schema` 只支持 `bitable` 导出为 `.base`,用于仅导出表结构
|
||||
- 如果格式不匹配,CLI 会返回 typed validation error,并在 `hint` 中给出可重试的 `--file-extension` 建议;例如 `docx + csv` 会提示改用 `docx/pdf/markdown`,或改传 sheet/bitable URL
|
||||
- shortcut 内部固定有限轮询:最多 10 次,每次间隔 5 秒
|
||||
- 轮询超时不是失败;会返回 `ticket`、`timed_out=true` 和 `next_command`,供后续继续查询
|
||||
|
||||
@@ -121,8 +150,7 @@ lark-cli drive +export \
|
||||
```bash
|
||||
# 第一步:先尝试直接导出
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--url "<DOCX_URL>" \
|
||||
--file-extension pdf \
|
||||
--file-name "weekly-report.pdf"
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
|
||||
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask / TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, Wiki URL and `--doc-type wiki` token `get_node -> export_tasks` planning, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
@@ -29,7 +29,7 @@
|
||||
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
|
||||
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
|
||||
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
|
||||
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
|
||||
| ✕ | drive +import | shortcut | | none | no import workflow yet |
|
||||
| ✕ | drive +move | shortcut | | none | no move workflow yet |
|
||||
|
||||
@@ -61,6 +61,96 @@ func TestDriveExportDryRun_FileNameMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+export",
|
||||
"--url", "https://example.feishu.cn/wiki/wikiDryRunExport",
|
||||
"--file-extension", "pdf",
|
||||
"--file-name", "wiki-report",
|
||||
"--output-dir", "./exports",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("api.0.params.token=%q, want wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.method").String(); got != "POST" {
|
||||
t.Fatalf("api.1.method=%q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/export_tasks" {
|
||||
t.Fatalf("api.1.url=%q, want export_tasks\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.token").String(); got != "obj_token_from_step_0" {
|
||||
t.Fatalf("api.1.body.token=%q, want resolved token placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.type").String(); got != "obj_type_from_step_0" {
|
||||
t.Fatalf("api.1.body.type=%q, want resolved type placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "wiki_token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("wiki_token=%q, want source wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "file_name").String(); got != "wiki-report.pdf" {
|
||||
t.Fatalf("file_name=%q, want wiki-report.pdf\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+export",
|
||||
"--token", "wikiDryRunExport",
|
||||
"--doc-type", "wiki",
|
||||
"--file-extension", "pdf",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("api.0.params.token=%q, want wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.token").String(); got != "obj_token_from_step_0" {
|
||||
t.Fatalf("api.1.body.token=%q, want resolved token placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.type").String(); got != "obj_type_from_step_0" {
|
||||
t.Fatalf("api.1.body.type=%q, want resolved type placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "wiki_token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("wiki_token=%q, want source wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user