mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
decc9549b5
* feat(sheets): reject local-office tokens in +workbook-export
A locally opened Office file (a local_office_ / fake_office_ token, or an
interleaved OFL0X one) names a file the Lark client is showing, not a cloud
document, so the drive export task can only fail on the backend -- and it
fails late, after the create and poll round trips, with an opaque message.
Refuse it up front with a typed failed_precondition that says the workbook is
already a file on disk, and points at +workbook-import for callers who want a
cloud spreadsheet they can export later. The check runs in Validate (so
--dry-run is covered too) and again after the wiki hop in Execute, where the
real spreadsheet token is first known.
* refactor(sheets): report success-path advisories in the result, not on stderr
Every sheets shortcut that had something to say on a successful run said it on
stderr: ignored sub-op locators, emulated dimension semantics, the deprecated
--dimension/--count and +cells-batch-set-style spellings, the dropdown
option-error steer, and the upload/export stage lines in the compatibility
layer. PowerShell's native-command handling and most agent harnesses read
non-empty stderr as failure, so a working call reported itself as an error --
and the facts a caller actually needed sat outside the JSON they parse.
Pure stage text ("Writing image", "Waiting for export task") is deleted: it
duplicates what the result already proves. Everything decision-relevant moves
into the payload:
- data.warnings ignored locators, colliding freezes, the dropdown
option-error steer (also shown in --dry-run now)
- data.effective_operation +dim-insert's anchor shift under --inherit-style
before, and the whole (rows, cols) state a freeze
leaves behind
- data.deprecation +cells-batch-set-style and +dim-freeze's legacy
flag pair, under a key of its own rather than
mixed into warnings
- data.upload how +media-upload sent the file
Clean calls keep their exact previous payload shape: every field above is
added only when it has something to report.
Scope is shortcuts/sheets/** on purpose. The remaining success-path stderr in
this domain comes from shared code (the drive export/import core behind
+workbook-export / +workbook-import, the multipart media helper, the auto-grant
helper), which other domains share; cleaning those up belongs to their own
change. The one sheets-owned exception is +workbook-import's extension
correction, which has no slot in the import core's output envelope -- it is
documented at the call site and allowlisted in the guard test.
Tests pin the contract (a successful run leaves stderr empty) and each new
field, plus a source scan that stops new direct ErrOut writes from appearing.
* docs(sheets): point the dropdown option-error warning at data.warnings
The --source-range flag help still told callers the option-error steer arrives
on stderr; it now rides in the result. Mirrors the same edit in the upstream
spec (canonical-spec/spec-tables/flags.json), so the next sync is a no-op.
* fix(sheets): keep export identifiers in +export output, tighten the stderr guard
Review follow-ups on the success-path stderr change:
- +export --output-path lost file_token: on the download branch the token
reached the caller only through the deleted "Export complete: file_token=…"
stderr line, and the payload carried just saved_path and size_bytes. Both
file_token and ticket now ride in the download result, so a caller can
re-download or resume without re-running the export.
- The stderr guard allowlisted a whole file, hiding any future write in it.
It now matches one exact statement in one file and asserts that write still
exists, so both a new write and a stale exception fail the test.
- The contract comments claimed more than the tests prove. They now state
that only sheets-OWNED code is silent, name the three commands whose noise
comes from shared implementations (+workbook-export, +workbook-import,
+media-upload over 20MB), and a new test pins that the shared export core
does still write -- failing, by design, once that core is cleaned up.
* fix(drive): keep the export and import cores off stderr on success
+workbook-export and +workbook-import delegate to drive.RunExport /
drive.RunImport, so the sheets success-path contract could not hold while
those cores narrated every step: task creation, each poll attempt, completion,
"still in progress", and the import's media upload. Callers that read
non-empty stderr as failure saw a finished export report itself as an error.
The stage text is deleted -- ticket, ready, status, file_token, token and
next_command are all already in the payload. What the narration alone carried
moves into the result:
- poll attempts / transient_failures / last_error, added only
when a poll actually had to be retried, so a caller can
tell a clean run from one that limped to the finish
- warnings markdown export falling back to the token as file name
after a failed title lookup
- input_corrections a caller-supplied record of inputs the CLI rewrote
before the request ran; sheets +workbook-import uses it
for a mislabeled .xls that is really an .xlsx, which was
its last stderr write
drive +export / +import get the same treatment, since they share these cores.
Clean runs keep their exact previous payload shape.
With this, the sheets stderr guard needs no allowlist, and the contract test
covers both workbook commands end to end. Two shared paths a sheets caller can
still reach stay noisy and are named in the contract comment: multipart media
upload over 20MB, and the bot-identity auto-grant warning.
* test(sheets): cover the annotation shapes and both guard call sites
Review follow-ups, all test-side except one comment:
- +dim-insert's effective_operation had no test: a regression could drop the
emulated-anchor block and still keep stderr empty. Now asserted field by
field, plus the negative case (--inherit-style after rewrites nothing, so it
must not gain the block).
- The local-office guard's second call site had no test. A /wiki/ URL only
reveals its backing token after get_node runs in Execute, so that branch is
now covered, asserting both the typed rejection and that no export task was
created.
- annotateSheetsResult's three payload shapes are pinned: object annotated in
place, array/scalar preserved under `result`, and an empty tool result left
without an invented `result: null`. The doc comment now spells out that last
case instead of lumping it in with non-object output.
- The export poll summary test asserted transient_failures but not attempts,
so a wrong or missing count would have passed.
* fix: preserve recovery state on failure paths and TTY liveness during polls
Review round 2. Removing the success-path narration also removed information
from paths that fail after remote work has started, and removed the only
liveness signal an interactive user had:
- drive +import / sheets +workbook-import: once the import task exists, the
ticket is the only handle back to it. A poll failure returned bare, so the
ticket -- previously visible through the polling line -- was lost. It now
rides on the typed error together with the +task_result command.
- sheets +export --output-path: a download or save failure happens after the
artifact is ready, so the error now carries ticket, file_token and the
+export-download command; re-running the whole export is not the recovery.
A poll timeout carries the ticket for the same reason.
- sheets +batch-update / +batch-chart-*: batch_update is fail-fast without
rollback, so the ignored-locator and colliding-freeze advisories matter most
exactly when the call fails part-way -- they decide the safe retry set. They
are now attached to the typed error's hint as well as the success payload.
- Bounded polls and the import upload are wrapped in RuntimeContext.StartSpinner,
which is gated on StderrIsTerminal and is a strict no-op for pipes, CI and
captured output. A human terminal gets liveness back; a machine caller's
stderr stays empty (the contract tests, which capture stderr, still pass).
+workbook-export's rejection of Office tokens also stopped assuming the caller
holds the file: a local_office_ / fake_office_ prefix means the workbook is
already on their disk, but an interleaved OFL0X token is a file stored in Lark
that may never have been downloaded, so that class is now pointed at
drive +download (then +workbook-import if they want a Lark spreadsheet).
Each behaviour above has a regression test; httpmock's CapturedBodies doc
comment is corrected, since it is appended on every match, not only for
Reusable stubs.
700 lines
23 KiB
Go
700 lines
23 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package drive
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/extension/fileio"
|
|
"github.com/larksuite/cli/internal/client"
|
|
"github.com/larksuite/cli/internal/validate"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
var (
|
|
driveExportPollAttempts = 10
|
|
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
|
|
SubID string
|
|
OnlySchema bool
|
|
}
|
|
|
|
type driveExportInputSource struct {
|
|
Type string
|
|
Token string
|
|
Param string
|
|
}
|
|
|
|
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 {
|
|
return fmt.Sprintf("lark-cli drive +task_result --scenario export --ticket %s --file-token %s", ticket, docToken)
|
|
}
|
|
|
|
// driveExportDownloadCommand prints a copy-pasteable follow-up command for
|
|
// downloading an already-generated export artifact by file token.
|
|
func driveExportDownloadCommand(fileToken, fileName, outputDir string, overwrite bool) string {
|
|
parts := []string{
|
|
"lark-cli", "drive", "+export-download",
|
|
"--file-token", strconv.Quote(fileToken),
|
|
}
|
|
if strings.TrimSpace(fileName) != "" {
|
|
parts = append(parts, "--file-name", strconv.Quote(fileName))
|
|
}
|
|
if strings.TrimSpace(outputDir) != "" && outputDir != "." {
|
|
parts = append(parts, "--output-dir", strconv.Quote(outputDir))
|
|
}
|
|
if overwrite {
|
|
parts = append(parts, "--overwrite")
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// driveExportStatus captures the fields needed to decide whether the export is
|
|
// ready for download, still pending, or terminally failed.
|
|
type driveExportStatus struct {
|
|
Ticket string
|
|
FileExtension string
|
|
DocType string
|
|
FileName string
|
|
FileToken string
|
|
JobErrorMsg string
|
|
FileSize int64
|
|
JobStatus int
|
|
}
|
|
|
|
func (s driveExportStatus) Ready() bool {
|
|
return s.FileToken != "" && s.JobStatus == 0
|
|
}
|
|
|
|
func (s driveExportStatus) Pending() bool {
|
|
// A zero status without a file token is still in progress because there is
|
|
// nothing downloadable yet.
|
|
return s.JobStatus == 1 || s.JobStatus == 2 || s.JobStatus == 0 && s.FileToken == ""
|
|
}
|
|
|
|
func (s driveExportStatus) Failed() bool {
|
|
return !s.Ready() && !s.Pending() && s.JobStatus != 0
|
|
}
|
|
|
|
func (s driveExportStatus) StatusLabel() string {
|
|
switch s.JobStatus {
|
|
case 0:
|
|
// Success is a special case where the file token is set.
|
|
if s.FileToken != "" {
|
|
return "success"
|
|
}
|
|
return "pending"
|
|
case 1:
|
|
return "new"
|
|
case 2:
|
|
return "processing"
|
|
case 3:
|
|
return "internal_error"
|
|
case 107:
|
|
return "export_size_limit"
|
|
case 108:
|
|
return "timeout"
|
|
case 109:
|
|
return "export_block_not_permitted"
|
|
case 110:
|
|
return "no_permission"
|
|
case 111:
|
|
return "docs_deleted"
|
|
case 122:
|
|
return "export_denied_on_copying"
|
|
case 123:
|
|
return "docs_not_exist"
|
|
case 6000:
|
|
return "export_images_exceed_limit"
|
|
default:
|
|
return fmt.Sprintf("status_%d", s.JobStatus)
|
|
}
|
|
}
|
|
|
|
// validateDriveExportSpec enforces shortcut-level export constraints before any
|
|
// backend request is sent.
|
|
func validateDriveExportSpec(spec driveExportSpec) error {
|
|
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 %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 %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, xlsx/csv/base for bitable, and pptx/pdf for slides")
|
|
}
|
|
|
|
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").
|
|
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").
|
|
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")
|
|
}
|
|
}
|
|
|
|
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").
|
|
WithHint("retry with --sub-id <sheet_id_or_table_id>; if you need the whole workbook, use --file-extension xlsx instead")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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"}
|
|
case "bitable":
|
|
return []string{"xlsx", "csv", "base"}
|
|
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,
|
|
}
|
|
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,
|
|
"file_extension": spec.FileExtension,
|
|
}
|
|
if strings.TrimSpace(spec.SubID) != "" {
|
|
body["sub_id"] = spec.SubID
|
|
}
|
|
if spec.OnlySchema {
|
|
body["only_schema"] = true
|
|
}
|
|
return 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 "", withDriveExportCreateRecovery(err)
|
|
}
|
|
|
|
ticket := common.GetString(data, "ticket")
|
|
if ticket == "" {
|
|
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "export task created but ticket is missing")
|
|
}
|
|
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")
|
|
}
|
|
|
|
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
|
|
}
|
|
// The resolution is reported through the result's wiki_token / wiki_node
|
|
// fields (see annotateDriveExportWikiOutput), not on stderr.
|
|
return spec, driveExportWikiResolution{
|
|
Resolved: true,
|
|
WikiToken: wikiToken,
|
|
ObjToken: objToken,
|
|
ObjType: objType,
|
|
}, nil
|
|
}
|
|
|
|
func createDriveExportTaskResolvingWiki(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 "", spec, driveExportWikiResolution{}, err
|
|
}
|
|
return ticket, spec, driveExportWikiResolution{}, nil
|
|
}
|
|
|
|
// getDriveExportStatus fetches the current backend state for a previously
|
|
// created export task.
|
|
func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) {
|
|
data, err := runtime.CallAPITyped(
|
|
"GET",
|
|
fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)),
|
|
map[string]interface{}{"token": token},
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return driveExportStatus{}, err
|
|
}
|
|
return parseDriveExportStatus(ticket, data), nil
|
|
}
|
|
|
|
// parseDriveExportStatus accepts the wrapped export result and normalizes the
|
|
// subset of fields used by the shortcut.
|
|
func parseDriveExportStatus(ticket string, data map[string]interface{}) driveExportStatus {
|
|
result := common.GetMap(data, "result")
|
|
status := driveExportStatus{
|
|
Ticket: ticket,
|
|
}
|
|
if result == nil {
|
|
// Keep the ticket even when the result body is missing so callers can
|
|
// still show a resumable task reference.
|
|
return status
|
|
}
|
|
|
|
status.FileExtension = common.GetString(result, "file_extension")
|
|
status.DocType = common.GetString(result, "type")
|
|
status.FileName = common.GetString(result, "file_name")
|
|
status.FileToken = common.GetString(result, "file_token")
|
|
status.JobErrorMsg = common.GetString(result, "job_error_msg")
|
|
status.FileSize = int64(common.GetFloat(result, "file_size"))
|
|
status.JobStatus = int(common.GetFloat(result, "job_status"))
|
|
return status
|
|
}
|
|
|
|
// saveContentToOutputDir validates the target path, enforces overwrite policy,
|
|
// and writes the payload atomically via FileIO.Save.
|
|
func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, payload []byte, overwrite bool) (string, error) {
|
|
if outputDir == "" {
|
|
outputDir = "."
|
|
}
|
|
|
|
// Sanitize both the filename and the combined output path so caller-provided
|
|
// names cannot escape the requested output directory.
|
|
safeName := sanitizeExportFileName(fileName, "export.bin")
|
|
target := filepath.Join(outputDir, safeName)
|
|
|
|
// Overwrite check via FileIO.Stat
|
|
if !overwrite {
|
|
if _, statErr := fio.Stat(target); statErr == nil {
|
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", target)
|
|
}
|
|
}
|
|
|
|
if _, err := fio.Save(target, fileio.SaveOptions{}, bytes.NewReader(payload)); err != nil {
|
|
return "", driveSaveError(err)
|
|
}
|
|
resolvedPath, _ := fio.ResolvePath(target)
|
|
if resolvedPath == "" {
|
|
resolvedPath = target
|
|
}
|
|
return resolvedPath, nil
|
|
}
|
|
|
|
// downloadDriveExportFile downloads the exported artifact, derives a safe local
|
|
// file name, and returns metadata about the saved file.
|
|
func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outputDir, preferredName string, overwrite bool) (map[string]interface{}, error) {
|
|
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
|
}
|
|
|
|
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
|
HttpMethod: http.MethodGet,
|
|
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
|
|
}, larkcore.WithFileDownload())
|
|
if err != nil {
|
|
return nil, wrapDriveNetworkErr(err, "download failed: %s", err)
|
|
}
|
|
if apiResp.StatusCode >= 400 {
|
|
subtype := errs.SubtypeNetworkTransport
|
|
if apiResp.StatusCode >= 500 {
|
|
subtype = errs.SubtypeNetworkServer
|
|
}
|
|
e := errs.NewNetworkError(subtype, "download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody)).WithCode(apiResp.StatusCode)
|
|
// Mirror internal/client streamLogID: fall back to the request-id header
|
|
// when log-id is absent so the diagnostic ID is still populated.
|
|
logID := strings.TrimSpace(apiResp.Header.Get(larkcore.HttpHeaderKeyLogId))
|
|
if logID == "" {
|
|
logID = strings.TrimSpace(apiResp.Header.Get(larkcore.HttpHeaderKeyRequestId))
|
|
}
|
|
if logID != "" {
|
|
e = e.WithLogID(logID)
|
|
}
|
|
return nil, e
|
|
}
|
|
|
|
fileName := strings.TrimSpace(preferredName)
|
|
if fileName == "" {
|
|
// Fall back to the server-provided download name when the caller did not
|
|
// request an explicit local file name.
|
|
fileName = client.ResolveFilename(apiResp)
|
|
}
|
|
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, apiResp.RawBody, overwrite)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"file_token": fileToken,
|
|
"file_name": filepath.Base(savedPath),
|
|
"saved_path": savedPath,
|
|
"size_bytes": len(apiResp.RawBody),
|
|
"content_type": apiResp.Header.Get("Content-Type"),
|
|
}, nil
|
|
}
|
|
|
|
// sanitizeExportFileName strips path traversal and unsupported characters while
|
|
// preserving a readable file name when possible.
|
|
func sanitizeExportFileName(name, fallback string) string {
|
|
name = strings.TrimSpace(filepath.Base(name))
|
|
if name == "" || name == "." || name == string(filepath.Separator) {
|
|
name = fallback
|
|
}
|
|
|
|
replacer := strings.NewReplacer(
|
|
"/", "_", "\\", "_", ":", "_", "*", "_", "?", "_",
|
|
"\"", "_", "<", "_", ">", "_", "|", "_",
|
|
"\n", "_", "\r", "_", "\t", "_", "\x00", "_",
|
|
)
|
|
name = replacer.Replace(name)
|
|
name = strings.Trim(name, ". ")
|
|
if name == "" || isWindowsReservedDeviceFileName(name) {
|
|
return fallback
|
|
}
|
|
return name
|
|
}
|
|
|
|
func isWindowsReservedDeviceFileName(name string) bool {
|
|
base := strings.TrimRight(name, ". ")
|
|
if dot := strings.IndexByte(base, '.'); dot >= 0 {
|
|
base = base[:dot]
|
|
}
|
|
switch strings.ToUpper(base) {
|
|
case "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$":
|
|
return true
|
|
}
|
|
if len(base) == 4 {
|
|
prefix := strings.ToUpper(base[:3])
|
|
suffix := base[3]
|
|
return (prefix == "COM" || prefix == "LPT") && suffix >= '1' && suffix <= '9'
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ensureExportFileExtension appends the expected local suffix when the chosen
|
|
// file name does not already end with the export format's extension.
|
|
func ensureExportFileExtension(name, fileExtension string) string {
|
|
expected := exportFileSuffix(fileExtension)
|
|
if expected == "" {
|
|
return name
|
|
}
|
|
if strings.EqualFold(filepath.Ext(name), expected) {
|
|
return name
|
|
}
|
|
return name + expected
|
|
}
|
|
|
|
// exportFileSuffix maps shortcut-level export formats to the local filename
|
|
// suffix written to disk.
|
|
func exportFileSuffix(fileExtension string) string {
|
|
switch fileExtension {
|
|
case "markdown":
|
|
return ".md"
|
|
case "docx":
|
|
return ".docx"
|
|
case "pdf":
|
|
return ".pdf"
|
|
case "xlsx":
|
|
return ".xlsx"
|
|
case "csv":
|
|
return ".csv"
|
|
case "base":
|
|
return ".base"
|
|
case "pptx":
|
|
return ".pptx"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|