mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
259 lines
9.5 KiB
Go
259 lines
9.5 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
//
|
|
// note +transcript — fetch the unified note transcript by a
|
|
// known note_id. The API is paginated; the CLI walks all pages internally,
|
|
// concatenates the content and saves the whole transcript to a local file.
|
|
|
|
package note
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"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/core"
|
|
"github.com/larksuite/cli/internal/i18n"
|
|
"github.com/larksuite/cli/internal/validate"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
const (
|
|
transcriptFormatMarkdown = "markdown"
|
|
transcriptFormatPlainText = "plain_text"
|
|
|
|
logPrefix = "[note +transcript]"
|
|
|
|
// maxTranscriptPages bounds the pagination loop so a misbehaving has_more
|
|
// can never spin forever. transcriptPageSize reduces round trips; full
|
|
// transcript correctness still depends on has_more/cursor pagination.
|
|
maxTranscriptPages = 500
|
|
transcriptPageSize = 200
|
|
|
|
// pageDelay throttles successive page requests to stay gentle on the
|
|
// downstream, matching the batch cadence used by `vc +notes`.
|
|
pageDelay = 100 * time.Millisecond
|
|
|
|
// noteArtifactSubdir is the default top-level directory for note-scoped
|
|
// artifacts (parallel to the "minutes" layout used by minute artifacts).
|
|
noteArtifactSubdir = "notes"
|
|
)
|
|
|
|
// NoteTranscript fetches the full unified transcript and saves it to a file.
|
|
var NoteTranscript = common.Shortcut{
|
|
Service: "note",
|
|
Command: "+transcript",
|
|
Description: "Fetch the unified note transcript and save it to a file",
|
|
Risk: "read",
|
|
Scopes: []string{"vc:note:read"},
|
|
AuthTypes: []string{"user"},
|
|
Flags: []common.Flag{
|
|
{Name: "note-id", Desc: "note ID", Required: true},
|
|
{Name: "transcript-format", Desc: "transcript content format", Default: transcriptFormatMarkdown, Enum: []string{transcriptFormatMarkdown, transcriptFormatPlainText}},
|
|
{Name: "locale", Desc: "transcript locale, e.g. zh_cn, en_us, ja_jp (default follows profile language or brand)"},
|
|
{Name: "output", Desc: "output file path (default: ./notes/{note_id}/unified_transcript.{md,txt})"},
|
|
{Name: "overwrite", Type: "bool", Desc: "overwrite an existing output file"},
|
|
},
|
|
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
|
|
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
|
if noteID == "" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--note-id is required").WithParam("--note-id")
|
|
}
|
|
if err := validate.ResourceName(noteID, "--note-id"); err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--note-id").WithCause(err)
|
|
}
|
|
if out := strings.TrimSpace(runtime.Str("output")); out != "" {
|
|
if err := common.ValidateSafePathTyped(runtime.FileIO(), out); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
|
transcriptFormat := runtime.Str("transcript-format")
|
|
locale := resolveTranscriptLocale(runtime)
|
|
return common.NewDryRunAPI().
|
|
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID))).
|
|
Desc("[1] Check note_display_type and verbatim_doc_token before transcript fetch").
|
|
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s/unified_note_transcript", validate.EncodePathSegment(noteID))).
|
|
Desc("[2] Fetch unified note transcript pages; subsequent pages add cursor_id internally").
|
|
Params(map[string]interface{}{
|
|
"format": transcriptFormat,
|
|
"page_size": transcriptPageSize,
|
|
"locale": locale,
|
|
}).
|
|
Set("transcript_format", transcriptFormat).
|
|
Set("locale", locale).
|
|
Set("note", "CLI first checks note_display_type via note detail, then paginates internally (cursor_id) and saves the full unified transcript to a file")
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
|
transcriptFormat := runtime.Str("transcript-format")
|
|
locale := resolveTranscriptLocale(runtime)
|
|
|
|
outPath := strings.TrimSpace(runtime.Str("output"))
|
|
if outPath == "" {
|
|
outPath = defaultTranscriptPath(noteID, transcriptFormat)
|
|
}
|
|
if !runtime.Bool("overwrite") {
|
|
if _, statErr := runtime.FileIO().Stat(outPath); statErr == nil {
|
|
precondition := errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s", outPath).
|
|
WithHint("Pass --overwrite to replace the existing file")
|
|
if strings.TrimSpace(runtime.Str("output")) != "" {
|
|
precondition = precondition.WithParam("--output")
|
|
}
|
|
return precondition
|
|
}
|
|
}
|
|
|
|
if err := ensureUnifiedNote(ctx, runtime, noteID); err != nil {
|
|
return err
|
|
}
|
|
|
|
content, err := fetchUnifiedTranscript(ctx, runtime, noteID, transcriptFormat, locale)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
saved, err := runtime.FileIO().Save(outPath, fileio.SaveOptions{}, bytes.NewReader(content))
|
|
if err != nil {
|
|
return common.WrapSaveErrorTyped(err)
|
|
}
|
|
resolved, rerr := runtime.FileIO().ResolvePath(outPath)
|
|
if rerr != nil || resolved == "" {
|
|
resolved = outPath
|
|
}
|
|
|
|
runtime.OutFormat(map[string]any{
|
|
"note_id": noteID,
|
|
"transcript_format": transcriptFormat,
|
|
"transcript_file": resolved,
|
|
"size_bytes": saved.Size(),
|
|
}, nil, nil)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
func ensureUnifiedNote(ctx context.Context, runtime *common.RuntimeContext, noteID string) error {
|
|
detail, err := FetchDetail(ctx, runtime, noteID)
|
|
if err != nil {
|
|
return mapNoteError(err)
|
|
}
|
|
if detail.DisplayType != "unified" {
|
|
if detail.VerbatimDocToken != "" {
|
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=%s)", noteID, detail.DisplayType, detail.VerbatimDocToken).
|
|
WithHint("Use docs +fetch --doc %s for normal note transcripts", detail.VerbatimDocToken)
|
|
}
|
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=)", noteID, detail.DisplayType).
|
|
WithHint("Use note +detail to inspect document tokens")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fetchUnifiedTranscript walks every page of the unified transcript and returns
|
|
// the concatenated content. Any page error fails the whole call: a partial
|
|
// transcript is misleading, so we prefer an explicit error over silent loss.
|
|
func fetchUnifiedTranscript(ctx context.Context, runtime *common.RuntimeContext, noteID, transcriptFormat, locale string) ([]byte, error) {
|
|
errOut := runtime.IO().ErrOut
|
|
apiPath := fmt.Sprintf("/open-apis/vc/v1/notes/%s/unified_note_transcript", validate.EncodePathSegment(noteID))
|
|
|
|
var buf bytes.Buffer
|
|
var cursor string
|
|
seenCursors := map[string]bool{}
|
|
for page := 1; ; page++ {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, transcriptContextError(err)
|
|
}
|
|
if page > maxTranscriptPages {
|
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript exceeded %d pages; aborting to avoid an unbounded loop", maxTranscriptPages)
|
|
}
|
|
|
|
query := larkcore.QueryParams{
|
|
"format": []string{transcriptFormat},
|
|
"locale": []string{locale},
|
|
"page_size": []string{strconv.Itoa(transcriptPageSize)},
|
|
}
|
|
if cursor != "" {
|
|
query["cursor_id"] = []string{cursor}
|
|
}
|
|
data, err := runtime.DoAPIJSONTyped(http.MethodGet, apiPath, query, nil)
|
|
if err != nil {
|
|
return nil, mapNoteError(err)
|
|
}
|
|
|
|
if transcript, _ := data["transcript"].(map[string]any); transcript != nil {
|
|
if chunk, _ := transcript[transcriptFormat].(string); chunk != "" {
|
|
buf.WriteString(chunk)
|
|
}
|
|
}
|
|
|
|
hasMore, _ := data["has_more"].(bool)
|
|
if !hasMore {
|
|
break
|
|
}
|
|
next, ok := parseLooseCursorID(data["next_cursor_id"])
|
|
if !ok || next == cursor || seenCursors[next] {
|
|
fmt.Fprintf(errOut, "%s has_more set but cursor did not advance at page %d\n", logPrefix, page)
|
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript pagination cursor did not advance at page %d; aborting to avoid saving a partial transcript", page)
|
|
}
|
|
seenCursors[cursor] = true
|
|
cursor = next
|
|
timer := time.NewTimer(pageDelay)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return nil, transcriptContextError(ctx.Err())
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
|
|
if buf.Len() == 0 {
|
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript is empty for note %s in %s format; aborting to avoid saving an empty transcript", noteID, transcriptFormat)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func transcriptContextError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
subtype := errs.SubtypeNetworkTransport
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
subtype = errs.SubtypeNetworkTimeout
|
|
}
|
|
return errs.NewNetworkError(subtype, "transcript fetch interrupted: %s", err).WithCause(err)
|
|
}
|
|
|
|
// defaultTranscriptPath builds the default save path for a note transcript.
|
|
func defaultTranscriptPath(noteID, transcriptFormat string) string {
|
|
name := "unified_transcript.md"
|
|
if transcriptFormat == transcriptFormatPlainText {
|
|
name = "unified_transcript.txt"
|
|
}
|
|
return filepath.Join(noteArtifactSubdir, noteID, name)
|
|
}
|
|
|
|
func resolveTranscriptLocale(runtime *common.RuntimeContext) string {
|
|
if explicit := strings.TrimSpace(runtime.Str("locale")); explicit != "" {
|
|
return explicit
|
|
}
|
|
if lang := runtime.Lang(); lang != "" {
|
|
return string(lang)
|
|
}
|
|
if runtime.Config.Brand == core.BrandLark {
|
|
return string(i18n.LangEnUS)
|
|
}
|
|
return string(i18n.LangZhCN)
|
|
}
|