Files
larksuite-cli/shortcuts/note/note_transcript.go
2026-06-26 14:32:09 +08:00

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)
}