chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user