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,209 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package note owns the Note domain: querying note detail and the unified
|
||||
// transcript by a known note_id. The vc domain locates a
|
||||
// note_id from meeting context and delegates note-detail parsing here, so the
|
||||
// parsing logic lives in exactly one place.
|
||||
package note
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// NoNoteReadPermissionCode is returned when the caller lacks read permission
|
||||
// for the requested note.
|
||||
const NoNoteReadPermissionCode = 121005
|
||||
|
||||
// ErrEmptyDetail identifies note detail responses that do not contain a note
|
||||
// object. Callers should use errors.Is instead of matching the display message.
|
||||
var ErrEmptyDetail = errors.New("note detail is empty")
|
||||
|
||||
// artifact_type enum from the note detail API.
|
||||
const (
|
||||
artifactTypeMainDoc = 1 // main note document
|
||||
artifactTypeVerbatim = 2 // verbatim transcript
|
||||
)
|
||||
|
||||
// note_display_type enum (i32) from the note detail API. Surfaced to callers as
|
||||
// a stable string so Agents route on a name, not a magic number.
|
||||
const (
|
||||
displayTypeNormal = 1
|
||||
displayTypeUnified = 2
|
||||
)
|
||||
|
||||
// Detail is the parsed note detail shared by `note +detail` and `vc +notes`.
|
||||
type Detail struct {
|
||||
NoteID string
|
||||
CreatorID string
|
||||
CreateTime string
|
||||
DisplayType string // unknown | normal | unified
|
||||
NoteDocToken string
|
||||
VerbatimDocToken string
|
||||
SharedDocTokens []string
|
||||
}
|
||||
|
||||
// FetchDetail queries GET /open-apis/vc/v1/notes/{note_id} and parses the note
|
||||
// object. API errors are returned as typed errs.* values so callers can enrich
|
||||
// user guidance without downgrading the envelope.
|
||||
func FetchDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) (*Detail, error) {
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
noteObj, _ := data["note"].(map[string]any)
|
||||
if noteObj == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "note detail is empty").WithCause(ErrEmptyDetail)
|
||||
}
|
||||
noteDoc, verbatimDoc := extractArtifactTokens(common.GetSlice(noteObj, "artifacts"))
|
||||
return &Detail{
|
||||
NoteID: noteID,
|
||||
CreatorID: common.GetString(noteObj, "creator_id"),
|
||||
CreateTime: common.FormatTime(noteObj["create_time"]),
|
||||
DisplayType: displayTypeString(displayTypeValue(noteObj)),
|
||||
NoteDocToken: noteDoc,
|
||||
VerbatimDocToken: verbatimDoc,
|
||||
SharedDocTokens: extractDocTokens(common.GetSlice(noteObj, "references")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ToMap renders the detail as the field map consumed by `vc +notes`, keeping
|
||||
// the historical key set (shared_doc_tokens omitted when empty) and adding the
|
||||
// note_id / note_display_type fields.
|
||||
func (d *Detail) ToMap() map[string]any {
|
||||
m := map[string]any{
|
||||
"note_id": d.NoteID,
|
||||
"note_display_type": d.DisplayType,
|
||||
"creator_id": d.CreatorID,
|
||||
"create_time": d.CreateTime,
|
||||
"note_doc_token": d.NoteDocToken,
|
||||
"verbatim_doc_token": d.VerbatimDocToken,
|
||||
}
|
||||
if len(d.SharedDocTokens) > 0 {
|
||||
m["shared_doc_tokens"] = d.SharedDocTokens
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// displayTypeValue reads the display-type field, tolerating either the
|
||||
// documented note_display_type key or a bare display_type fallback.
|
||||
func displayTypeValue(note map[string]any) any {
|
||||
if v, ok := note["note_display_type"]; ok {
|
||||
return v
|
||||
}
|
||||
return note["display_type"]
|
||||
}
|
||||
|
||||
func displayTypeString(v any) string {
|
||||
switch parseLooseInt(v) {
|
||||
case displayTypeNormal:
|
||||
return "normal"
|
||||
case displayTypeUnified:
|
||||
return "unified"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// extractArtifactTokens picks main-doc and verbatim-doc tokens from artifacts.
|
||||
func extractArtifactTokens(artifacts []any) (noteDoc, verbatimDoc string) {
|
||||
for _, a := range artifacts {
|
||||
artifact, _ := a.(map[string]any)
|
||||
if artifact == nil {
|
||||
continue
|
||||
}
|
||||
docToken, _ := artifact["doc_token"].(string)
|
||||
switch parseLooseInt(artifact["artifact_type"]) {
|
||||
case artifactTypeMainDoc:
|
||||
noteDoc = docToken
|
||||
case artifactTypeVerbatim:
|
||||
verbatimDoc = docToken
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// extractDocTokens collects doc_token values from a list of reference objects.
|
||||
func extractDocTokens(refs []any) []string {
|
||||
var tokens []string
|
||||
for _, s := range refs {
|
||||
source, _ := s.(map[string]any)
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
if docToken, _ := source["doc_token"].(string); docToken != "" {
|
||||
tokens = append(tokens, docToken)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// parseLooseInt extracts an int from the varying JSON number representations
|
||||
// DoAPIJSON may yield (json.Number, float64, or int).
|
||||
func parseLooseInt(v any) int {
|
||||
switch n := v.(type) {
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return int(i)
|
||||
case float64:
|
||||
// Reject fractional values: truncating 1.9 to 1 would silently coerce
|
||||
// a malformed enum into a valid one.
|
||||
if n != float64(int64(n)) {
|
||||
return 0
|
||||
}
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// parseLooseCursorID extracts a positive cursor as a string. String cursors are
|
||||
// preferred because large JSON numbers lose precision when decoded into any.
|
||||
func parseLooseCursorID(v any) (string, bool) {
|
||||
switch n := v.(type) {
|
||||
case string:
|
||||
s := strings.TrimSpace(n)
|
||||
if s == "" || s == "0" {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err != nil || i <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatInt(i, 10), true
|
||||
case float64:
|
||||
// encoding/json decodes numbers in map[string]any as float64. Accept
|
||||
// only values that can round-trip safely as an integer cursor.
|
||||
const maxSafeJSONInteger = 1<<53 - 1
|
||||
if n <= 0 || n != float64(int64(n)) || n > maxSafeJSONInteger {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatInt(int64(n), 10), true
|
||||
case int64:
|
||||
if n <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatInt(n, 10), true
|
||||
case int:
|
||||
if n <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.Itoa(n), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// note +detail — get note metadata and document tokens by a known note_id.
|
||||
|
||||
package note
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// NoteDetail queries note metadata, display type and document tokens by note_id.
|
||||
var NoteDetail = common.Shortcut{
|
||||
Service: "note",
|
||||
Command: "+detail",
|
||||
Description: "Get note detail (display type, document tokens) by note_id",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:note:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "note-id", Desc: "note ID", Required: true},
|
||||
},
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
||||
detail, err := FetchDetail(ctx, runtime, noteID)
|
||||
if err != nil {
|
||||
return mapNoteError(err)
|
||||
}
|
||||
runtime.OutFormat(map[string]any{"note": detail.ToMap()}, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// mapNoteError surfaces the no-permission case explicitly and passes through
|
||||
// any other typed API error unchanged.
|
||||
func mapNoteError(err error) error {
|
||||
if problem, ok := errs.ProblemOf(err); ok && problem.Code == NoNoteReadPermissionCode {
|
||||
message := strings.TrimSpace(problem.Message)
|
||||
if message == "" {
|
||||
message = "no read permission for this note"
|
||||
} else if !strings.Contains(message, "no read permission for this note") {
|
||||
message = fmt.Sprintf("no read permission for this note: %s", message)
|
||||
}
|
||||
var permErr *errs.PermissionError
|
||||
if errors.As(err, &permErr) {
|
||||
mapped := *permErr
|
||||
mapped.Problem.Message = message
|
||||
if mapped.Problem.Hint == "" {
|
||||
mapped.Problem.Hint = "Ask the note owner to grant read permission, then retry"
|
||||
}
|
||||
mapped.Cause = err
|
||||
return &mapped
|
||||
}
|
||||
mappedProblem := *problem
|
||||
mappedProblem.Category = errs.CategoryAuthorization
|
||||
mappedProblem.Subtype = errs.SubtypePermissionDenied
|
||||
mappedProblem.Message = message
|
||||
if mappedProblem.Hint == "" {
|
||||
mappedProblem.Hint = "Ask the note owner to grant read permission, then retry"
|
||||
}
|
||||
return &errs.PermissionError{Problem: mappedProblem, Cause: err}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package note
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// These tests were relocated from shortcuts/vc/vc_notes_test.go together with
|
||||
// the note-detail parsing helpers they cover.
|
||||
|
||||
func TestParseLooseInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
input any
|
||||
want int
|
||||
}{
|
||||
{float64(1), 1},
|
||||
{float64(2), 2},
|
||||
{float64(1.9), 0},
|
||||
{json.Number("3"), 3},
|
||||
{"unknown", 0},
|
||||
{nil, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parseLooseInt(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseLooseInt(%v) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLooseCursorID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{name: "string", in: "7648924766078847940", want: "7648924766078847940", ok: true},
|
||||
{name: "trim string", in: " 123 ", want: "123", ok: true},
|
||||
{name: "empty string", in: "", ok: false},
|
||||
{name: "zero string", in: "0", ok: false},
|
||||
{name: "json number", in: json.Number("123"), want: "123", ok: true},
|
||||
{name: "float safe integer", in: float64(123), want: "123", ok: true},
|
||||
{name: "float unsafe integer", in: float64(1<<53 + 1), ok: false},
|
||||
{name: "float fractional", in: float64(1.5), ok: false},
|
||||
{name: "negative", in: -1, ok: false},
|
||||
{name: "nil", in: nil, ok: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := parseLooseCursorID(tt.in)
|
||||
if got != tt.want || ok != tt.ok {
|
||||
t.Fatalf("parseLooseCursorID(%v) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractArtifactTokens(t *testing.T) {
|
||||
artifacts := []any{
|
||||
map[string]any{"doc_token": "main_doc", "artifact_type": float64(1)},
|
||||
map[string]any{"doc_token": "verbatim_doc", "artifact_type": float64(2)},
|
||||
map[string]any{"doc_token": "unknown_doc", "artifact_type": float64(99)},
|
||||
nil,
|
||||
}
|
||||
noteDoc, verbatimDoc := extractArtifactTokens(artifacts)
|
||||
if noteDoc != "main_doc" {
|
||||
t.Errorf("noteDoc = %q, want %q", noteDoc, "main_doc")
|
||||
}
|
||||
if verbatimDoc != "verbatim_doc" {
|
||||
t.Errorf("verbatimDoc = %q, want %q", verbatimDoc, "verbatim_doc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractArtifactTokens_Empty(t *testing.T) {
|
||||
noteDoc, verbatimDoc := extractArtifactTokens(nil)
|
||||
if noteDoc != "" || verbatimDoc != "" {
|
||||
t.Errorf("expected empty tokens for nil input, got %q, %q", noteDoc, verbatimDoc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDocTokens(t *testing.T) {
|
||||
refs := []any{
|
||||
map[string]any{"doc_token": "shared1"},
|
||||
map[string]any{"doc_token": "shared2"},
|
||||
map[string]any{"doc_token": ""},
|
||||
map[string]any{},
|
||||
nil,
|
||||
}
|
||||
tokens := extractDocTokens(refs)
|
||||
if len(tokens) != 2 || tokens[0] != "shared1" || tokens[1] != "shared2" {
|
||||
t.Errorf("extractDocTokens = %v, want [shared1 shared2]", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDocTokens_Empty(t *testing.T) {
|
||||
tokens := extractDocTokens(nil)
|
||||
if tokens != nil {
|
||||
t.Errorf("expected nil for nil input, got %v", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailToMap(t *testing.T) {
|
||||
detail := &Detail{
|
||||
NoteID: "note_1",
|
||||
CreatorID: "creator_1",
|
||||
CreateTime: "2026-06-09 12:00:00",
|
||||
DisplayType: "unified",
|
||||
NoteDocToken: "note_doc",
|
||||
VerbatimDocToken: "verbatim_doc",
|
||||
SharedDocTokens: []string{"shared_1", "shared_2"},
|
||||
}
|
||||
|
||||
got := detail.ToMap()
|
||||
want := map[string]any{
|
||||
"note_id": "note_1",
|
||||
"creator_id": "creator_1",
|
||||
"create_time": "2026-06-09 12:00:00",
|
||||
"note_display_type": "unified",
|
||||
"note_doc_token": "note_doc",
|
||||
"verbatim_doc_token": "verbatim_doc",
|
||||
"shared_doc_tokens": []string{"shared_1", "shared_2"},
|
||||
}
|
||||
for key, wantValue := range want {
|
||||
gotValue, ok := got[key]
|
||||
if !ok {
|
||||
t.Fatalf("ToMap missing key %q in %#v", key, got)
|
||||
}
|
||||
if !valuesEqual(gotValue, wantValue) {
|
||||
t.Fatalf("ToMap[%q] = %#v, want %#v", key, gotValue, wantValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailToMap_OmitsEmptySharedDocTokens(t *testing.T) {
|
||||
got := (&Detail{NoteID: "note_1"}).ToMap()
|
||||
if _, ok := got["shared_doc_tokens"]; ok {
|
||||
t.Fatalf("ToMap should omit empty shared_doc_tokens, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapNoteError_NoReadPermission(t *testing.T) {
|
||||
err := &errs.PermissionError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthorization,
|
||||
Subtype: errs.SubtypePermissionDenied,
|
||||
Code: NoNoteReadPermissionCode,
|
||||
Message: "upstream permission denied",
|
||||
LogID: "log_1",
|
||||
},
|
||||
MissingScopes: []string{"vc:note:read"},
|
||||
Identity: "user",
|
||||
}
|
||||
|
||||
got := mapNoteError(err)
|
||||
problem, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("mapNoteError returned %T, want typed problem", got)
|
||||
}
|
||||
if problem.Code != NoNoteReadPermissionCode {
|
||||
t.Fatalf("mapped code = %d, want %d", problem.Code, NoNoteReadPermissionCode)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "no read permission for this note") || !strings.Contains(problem.Message, "upstream permission denied") {
|
||||
t.Fatalf("mapped message = %q, want note permission guidance with upstream message", problem.Message)
|
||||
}
|
||||
if !errors.Is(got, err) {
|
||||
t.Fatal("mapped error should preserve the original typed error as cause")
|
||||
}
|
||||
originalProblem, _ := errs.ProblemOf(err)
|
||||
if originalProblem.Message != "upstream permission denied" {
|
||||
t.Fatalf("original message was mutated to %q", originalProblem.Message)
|
||||
}
|
||||
var gotPerm *errs.PermissionError
|
||||
if !errors.As(got, &gotPerm) {
|
||||
t.Fatalf("mapped error = %T, want PermissionError", got)
|
||||
}
|
||||
if gotPerm.LogID != "log_1" {
|
||||
t.Fatalf("LogID = %q, want preserved log_1", gotPerm.LogID)
|
||||
}
|
||||
if len(gotPerm.MissingScopes) != 1 || gotPerm.MissingScopes[0] != "vc:note:read" {
|
||||
t.Fatalf("MissingScopes = %#v, want preserved vc:note:read", gotPerm.MissingScopes)
|
||||
}
|
||||
if gotPerm.Identity != "user" {
|
||||
t.Fatalf("Identity = %q, want preserved user", gotPerm.Identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapNoteError_NormalizesNonPermissionTypedError(t *testing.T) {
|
||||
err := &errs.APIError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Code: NoNoteReadPermissionCode,
|
||||
Message: "upstream api error",
|
||||
LogID: "log_2",
|
||||
},
|
||||
}
|
||||
|
||||
got := mapNoteError(err)
|
||||
var gotPerm *errs.PermissionError
|
||||
if !errors.As(got, &gotPerm) {
|
||||
t.Fatalf("mapped error = %T, want PermissionError", got)
|
||||
}
|
||||
if gotPerm.Category != errs.CategoryAuthorization || gotPerm.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Fatalf("mapped category/subtype = %q/%q, want authorization/permission_denied", gotPerm.Category, gotPerm.Subtype)
|
||||
}
|
||||
if !strings.Contains(gotPerm.Message, "no read permission for this note") || !strings.Contains(gotPerm.Message, "upstream api error") {
|
||||
t.Fatalf("mapped message = %q, want note permission guidance with upstream message", gotPerm.Message)
|
||||
}
|
||||
if gotPerm.Hint == "" {
|
||||
t.Fatal("mapped hint should not be empty")
|
||||
}
|
||||
if gotPerm.LogID != "log_2" {
|
||||
t.Fatalf("LogID = %q, want preserved log_2", gotPerm.LogID)
|
||||
}
|
||||
if !errors.Is(got, err) {
|
||||
t.Fatal("mapped error should preserve the original typed error as cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapNoteError_Passthrough(t *testing.T) {
|
||||
err := errors.New("boom")
|
||||
if got := mapNoteError(err); got != err {
|
||||
t.Fatalf("mapNoteError passthrough = %v, want original", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteDetailEmptyDetailPreservesSentinelCause(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_empty_detail",
|
||||
Body: map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteDetail, []string{"+detail", "--note-id", "note_empty_detail", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty detail to fail")
|
||||
}
|
||||
if !errors.Is(err, ErrEmptyDetail) {
|
||||
t.Fatalf("errors.Is(ErrEmptyDetail) = false for %T: %v", err, err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("category/subtype = %v/%v, want Internal/InvalidResponse", problem.Category, problem.Subtype)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcuts(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 2 {
|
||||
t.Fatalf("Shortcuts len = %d, want 2", len(shortcuts))
|
||||
}
|
||||
if shortcuts[0].Command != "+detail" || shortcuts[1].Command != "+transcript" {
|
||||
t.Fatalf("Shortcuts commands = %q, %q", shortcuts[0].Command, shortcuts[1].Command)
|
||||
}
|
||||
}
|
||||
|
||||
func valuesEqual(a, b any) bool {
|
||||
ab, _ := json.Marshal(a)
|
||||
bb, _ := json.Marshal(b)
|
||||
return string(ab) == string(bb)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package note
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestNoteTranscriptRequiresUnifiedNote(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
reg.Register(noteDetailStub("note_normal", displayTypeNormal))
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_normal", "--output", "out.md", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-unified note to fail")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "not a unified note") || !strings.Contains(got, "note_display_type=normal") || !strings.Contains(got, "verbatim_doc_token=doc_verbatim") {
|
||||
t.Fatalf("err = %q, want non-unified message", got)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype = %v, want FailedPrecondition", problem.Subtype)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "docs +fetch --doc doc_verbatim") {
|
||||
t.Fatalf("hint = %q, want docs +fetch guidance", problem.Hint)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptFetchesUnifiedNote(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_unified", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_unified/unified_note_transcript?format=markdown&locale=zh_cn&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "# transcript\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_unified", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(dir, "notes", "note_unified", "unified_transcript.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile transcript err=%v", err)
|
||||
}
|
||||
if string(content) != "# transcript\n" {
|
||||
t.Fatalf("transcript = %q, want %q", string(content), "# transcript\n")
|
||||
}
|
||||
data := decodeNoteEnvelope(t, stdout)
|
||||
if data["note_id"] != "note_unified" || data["size_bytes"] != float64(len(content)) {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptFormatFlagDoesNotShadowOutputFormat(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_plain", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_plain/unified_note_transcript?format=plain_text&locale=zh_cn&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"transcript": map[string]interface{}{
|
||||
"plain_text": "plain transcript\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{
|
||||
"+transcript",
|
||||
"--note-id", "note_plain",
|
||||
"--transcript-format", "plain_text",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(dir, "notes", "note_plain", "unified_transcript.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile transcript err=%v", err)
|
||||
}
|
||||
if string(content) != "plain transcript\n" {
|
||||
t.Fatalf("transcript = %q, want plain transcript", string(content))
|
||||
}
|
||||
data := decodeNoteEnvelope(t, stdout)
|
||||
if data["transcript_format"] != "plain_text" {
|
||||
t.Fatalf("transcript_format = %#v, want plain_text; output=%s", data["transcript_format"], stdout.String())
|
||||
}
|
||||
if _, ok := data["format"]; ok {
|
||||
t.Fatalf("output should not expose ambiguous format field: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptPassesLocaleThrough(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_locale", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_locale/unified_note_transcript?format=markdown&locale=en_us&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "# en transcript\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_locale", "--locale", "en_us", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(dir, "notes", "note_locale", "unified_transcript.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile transcript err=%v", err)
|
||||
}
|
||||
if string(content) != "# en transcript\n" {
|
||||
t.Fatalf("transcript = %q, want en transcript", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptDefaultsLocaleFromLarkBrand(t *testing.T) {
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app-lark-locale",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandLark,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
factory, stdout, _, reg := noteShortcutTestFactoryWithConfig(t, config)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_lark", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_lark/unified_note_transcript?format=markdown&locale=en_us&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "# en transcript\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_lark", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptRejectsExistingOutputBeforeFetch(t *testing.T) {
|
||||
factory, stdout, _, _ := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
outPath := filepath.Join("notes", "note_exists", "unified_transcript.md")
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
|
||||
t.Fatalf("MkdirAll err=%v", err)
|
||||
}
|
||||
if err := os.WriteFile(outPath, []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile err=%v", err)
|
||||
}
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_exists", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected existing output to fail")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "output file already exists") {
|
||||
t.Fatalf("err = %q, want existing output error", got)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("err = %T, want ValidationError", err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype = %v, want FailedPrecondition", validationErr.Subtype)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "--overwrite") {
|
||||
t.Fatalf("hint = %q, want --overwrite guidance", validationErr.Hint)
|
||||
}
|
||||
// The CLI picked the default path itself, so no input param is at fault.
|
||||
if validationErr.Param != "" {
|
||||
t.Fatalf("param = %q, want empty for default output path", validationErr.Param)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptRejectsEmptyTranscript(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_empty", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_empty/unified_note_transcript?format=markdown&locale=zh_cn&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_empty", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty transcript to fail")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "transcript is empty") || !strings.Contains(got, "note_empty") {
|
||||
t.Fatalf("err = %q, want empty transcript error", got)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("category/subtype = %v/%v, want Internal/InvalidResponse", problem.Category, problem.Subtype)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "notes", "note_empty", "unified_transcript.md")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("transcript file should not exist, statErr=%v", statErr)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptRejectsCursorCycle(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_cycle", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_cycle/unified_note_transcript?format=markdown&locale=zh_cn&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"next_cursor_id": "A",
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "page1\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "cursor_id=A",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"next_cursor_id": "B",
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "page2\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "cursor_id=B",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"next_cursor_id": "A",
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "page3\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_cycle", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected cursor cycle to fail")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "pagination cursor did not advance") {
|
||||
t.Fatalf("err = %q, want cursor advance error", got)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("category/subtype = %v/%v, want Internal/InvalidResponse", problem.Category, problem.Subtype)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "notes", "note_cycle", "unified_transcript.md")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("transcript file should not exist, statErr=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptContextErrorPreservesCause(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
subtype errs.Subtype
|
||||
}{
|
||||
{
|
||||
name: "canceled",
|
||||
err: context.Canceled,
|
||||
subtype: errs.SubtypeNetworkTransport,
|
||||
},
|
||||
{
|
||||
name: "deadline",
|
||||
err: context.DeadlineExceeded,
|
||||
subtype: errs.SubtypeNetworkTimeout,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := transcriptContextError(tt.err)
|
||||
if !errors.Is(err, tt.err) {
|
||||
t.Fatalf("errors.Is(%v) = false", tt.err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryNetwork || problem.Subtype != tt.subtype {
|
||||
t.Fatalf("category/subtype = %v/%v, want Network/%v", problem.Category, problem.Subtype, tt.subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func noteShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
return noteShortcutTestFactoryWithConfig(t, config)
|
||||
}
|
||||
|
||||
func noteShortcutTestFactoryWithConfig(t *testing.T, config *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
return cmdutil.TestFactory(t, config)
|
||||
}
|
||||
|
||||
func runNoteShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "note"}
|
||||
shortcut.Mount(parent, factory)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
stdout.Reset()
|
||||
if stderr, ok := factory.IOStreams.ErrOut.(*bytes.Buffer); ok {
|
||||
stderr.Reset()
|
||||
}
|
||||
return parent.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func noteDetailStub(noteID string, displayType int) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/" + noteID,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"note": map[string]interface{}{
|
||||
"note_display_type": displayType,
|
||||
"artifacts": []interface{}{
|
||||
map[string]interface{}{"artifact_type": artifactTypeVerbatim, "doc_token": "doc_verbatim"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func decodeNoteEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if data, _ := envelope["data"].(map[string]interface{}); data != nil {
|
||||
return data
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package note
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all note-domain shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
NoteDetail,
|
||||
NoteTranscript,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user