Skip to content

Commit cbbf3ed

Browse files
committed
feat: add note domain transcript support
1 parent c000dc3 commit cbbf3ed

17 files changed

Lines changed: 1076 additions & 170 deletions

shortcuts/note/note.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
// Package note owns the Note domain: querying note detail and the unified
5+
// transcript by a known note_id. The vc domain locates a
6+
// note_id from meeting context and delegates note-detail parsing here, so the
7+
// parsing logic lives in exactly one place.
8+
package note
9+
10+
import (
11+
"context"
12+
"encoding/json"
13+
"fmt"
14+
"net/http"
15+
"strconv"
16+
"strings"
17+
18+
"github.com/larksuite/cli/internal/output"
19+
"github.com/larksuite/cli/internal/validate"
20+
"github.com/larksuite/cli/shortcuts/common"
21+
)
22+
23+
// NoNoteReadPermissionCode is returned when the caller lacks read permission
24+
// for the requested note.
25+
const NoNoteReadPermissionCode = 121005
26+
27+
// artifact_type enum from the note detail API.
28+
const (
29+
artifactTypeMainDoc = 1 // main note document
30+
artifactTypeVerbatim = 2 // verbatim transcript
31+
)
32+
33+
// note_display_type enum (i32) from the note detail API. Surfaced to callers as
34+
// a stable string so Agents route on a name, not a magic number.
35+
const (
36+
displayTypeNormal = 1
37+
displayTypeUnified = 2
38+
)
39+
40+
// Detail is the parsed note detail shared by `note +detail` and `vc +notes`.
41+
type Detail struct {
42+
NoteID string
43+
CreatorID string
44+
CreateTime string
45+
DisplayType string // unknown | normal | unified
46+
NoteDocToken string
47+
VerbatimDocToken string
48+
SharedDocTokens []string
49+
}
50+
51+
// FetchDetail queries GET /open-apis/vc/v1/notes/{note_id} and parses the note
52+
// object. The raw API error is returned untouched so callers map the
53+
// no-permission case to their own user-facing message.
54+
func FetchDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) (*Detail, error) {
55+
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
56+
if err != nil {
57+
return nil, err
58+
}
59+
noteObj, _ := data["note"].(map[string]any)
60+
if noteObj == nil {
61+
return nil, output.ErrAPI(0, "note detail is empty", nil)
62+
}
63+
noteDoc, verbatimDoc := extractArtifactTokens(common.GetSlice(noteObj, "artifacts"))
64+
return &Detail{
65+
NoteID: noteID,
66+
CreatorID: common.GetString(noteObj, "creator_id"),
67+
CreateTime: common.FormatTime(noteObj["create_time"]),
68+
DisplayType: displayTypeString(displayTypeValue(noteObj)),
69+
NoteDocToken: noteDoc,
70+
VerbatimDocToken: verbatimDoc,
71+
SharedDocTokens: extractDocTokens(common.GetSlice(noteObj, "references")),
72+
}, nil
73+
}
74+
75+
// ToMap renders the detail as the field map consumed by `vc +notes`, keeping
76+
// the historical key set (shared_doc_tokens omitted when empty) and adding the
77+
// note_id / note_display_type fields.
78+
func (d *Detail) ToMap() map[string]any {
79+
m := map[string]any{
80+
"note_id": d.NoteID,
81+
"note_display_type": d.DisplayType,
82+
"creator_id": d.CreatorID,
83+
"create_time": d.CreateTime,
84+
"note_doc_token": d.NoteDocToken,
85+
"verbatim_doc_token": d.VerbatimDocToken,
86+
}
87+
if len(d.SharedDocTokens) > 0 {
88+
m["shared_doc_tokens"] = d.SharedDocTokens
89+
}
90+
return m
91+
}
92+
93+
// displayTypeValue reads the display-type field, tolerating either the
94+
// documented note_display_type key or a bare display_type fallback.
95+
func displayTypeValue(note map[string]any) any {
96+
if v, ok := note["note_display_type"]; ok {
97+
return v
98+
}
99+
return note["display_type"]
100+
}
101+
102+
func displayTypeString(v any) string {
103+
switch parseLooseInt(v) {
104+
case displayTypeNormal:
105+
return "normal"
106+
case displayTypeUnified:
107+
return "unified"
108+
default:
109+
return "unknown"
110+
}
111+
}
112+
113+
// extractArtifactTokens picks main-doc and verbatim-doc tokens from artifacts.
114+
func extractArtifactTokens(artifacts []any) (noteDoc, verbatimDoc string) {
115+
for _, a := range artifacts {
116+
artifact, _ := a.(map[string]any)
117+
if artifact == nil {
118+
continue
119+
}
120+
docToken, _ := artifact["doc_token"].(string)
121+
switch parseLooseInt(artifact["artifact_type"]) {
122+
case artifactTypeMainDoc:
123+
noteDoc = docToken
124+
case artifactTypeVerbatim:
125+
verbatimDoc = docToken
126+
}
127+
}
128+
return
129+
}
130+
131+
// extractDocTokens collects doc_token values from a list of reference objects.
132+
func extractDocTokens(refs []any) []string {
133+
var tokens []string
134+
for _, s := range refs {
135+
source, _ := s.(map[string]any)
136+
if source == nil {
137+
continue
138+
}
139+
if docToken, _ := source["doc_token"].(string); docToken != "" {
140+
tokens = append(tokens, docToken)
141+
}
142+
}
143+
return tokens
144+
}
145+
146+
// parseLooseInt extracts an int from the varying JSON number representations
147+
// DoAPIJSON may yield (json.Number, float64, or int).
148+
func parseLooseInt(v any) int {
149+
switch n := v.(type) {
150+
case json.Number:
151+
i, _ := n.Int64()
152+
return int(i)
153+
case float64:
154+
return int(n)
155+
case int:
156+
return n
157+
default:
158+
return 0
159+
}
160+
}
161+
162+
// parseLooseCursorID extracts a positive cursor as a string. String cursors are
163+
// preferred because large JSON numbers lose precision when decoded into any.
164+
func parseLooseCursorID(v any) (string, bool) {
165+
switch n := v.(type) {
166+
case string:
167+
s := strings.TrimSpace(n)
168+
if s == "" || s == "0" {
169+
return "", false
170+
}
171+
return s, true
172+
case json.Number:
173+
i, err := n.Int64()
174+
if err != nil || i <= 0 {
175+
return "", false
176+
}
177+
return strconv.FormatInt(i, 10), true
178+
case float64:
179+
// encoding/json decodes numbers in map[string]any as float64. Accept
180+
// only values that can round-trip safely as an integer cursor.
181+
const maxSafeJSONInteger = 1<<53 - 1
182+
if n <= 0 || n != float64(int64(n)) || n > maxSafeJSONInteger {
183+
return "", false
184+
}
185+
return strconv.FormatInt(int64(n), 10), true
186+
case int64:
187+
if n <= 0 {
188+
return "", false
189+
}
190+
return strconv.FormatInt(n, 10), true
191+
case int:
192+
if n <= 0 {
193+
return "", false
194+
}
195+
return strconv.Itoa(n), true
196+
default:
197+
return "", false
198+
}
199+
}

shortcuts/note/note_detail.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
//
4+
// note +detail — get note metadata and document tokens by a known note_id.
5+
6+
package note
7+
8+
import (
9+
"context"
10+
"errors"
11+
"fmt"
12+
"strings"
13+
14+
"github.com/larksuite/cli/internal/output"
15+
"github.com/larksuite/cli/internal/validate"
16+
"github.com/larksuite/cli/shortcuts/common"
17+
)
18+
19+
// NoteDetail queries note metadata, display type and document tokens by note_id.
20+
var NoteDetail = common.Shortcut{
21+
Service: "note",
22+
Command: "+detail",
23+
Description: "Get note detail (display type, document tokens) by note_id",
24+
Risk: "read",
25+
Scopes: []string{"vc:note:read"},
26+
AuthTypes: []string{"user"},
27+
Flags: []common.Flag{
28+
{Name: "note-id", Desc: "note ID", Required: true},
29+
},
30+
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
31+
noteID := strings.TrimSpace(runtime.Str("note-id"))
32+
if noteID == "" {
33+
return output.ErrValidation("--note-id is required")
34+
}
35+
if err := validate.ResourceName(noteID, "--note-id"); err != nil {
36+
return output.ErrValidation("%s", err)
37+
}
38+
return nil
39+
},
40+
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
41+
noteID := strings.TrimSpace(runtime.Str("note-id"))
42+
return common.NewDryRunAPI().
43+
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)))
44+
},
45+
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
46+
noteID := strings.TrimSpace(runtime.Str("note-id"))
47+
detail, err := FetchDetail(ctx, runtime, noteID)
48+
if err != nil {
49+
return mapNoteError(err)
50+
}
51+
runtime.OutFormat(map[string]any{"note": detail.ToMap()}, nil, nil)
52+
return nil
53+
},
54+
}
55+
56+
// mapNoteError surfaces the no-permission case explicitly and passes through
57+
// any other API error unchanged.
58+
func mapNoteError(err error) error {
59+
var exitErr *output.ExitError
60+
if errors.As(err, &exitErr) && exitErr.Detail != nil && exitErr.Detail.Code == NoNoteReadPermissionCode {
61+
return output.ErrAPI(NoNoteReadPermissionCode, "no read permission for this note", nil)
62+
}
63+
return err
64+
}

shortcuts/note/note_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package note
5+
6+
import (
7+
"encoding/json"
8+
"testing"
9+
)
10+
11+
// These tests were relocated from shortcuts/vc/vc_notes_test.go together with
12+
// the note-detail parsing helpers they cover.
13+
14+
func TestParseLooseInt(t *testing.T) {
15+
tests := []struct {
16+
input any
17+
want int
18+
}{
19+
{float64(1), 1},
20+
{float64(2), 2},
21+
{json.Number("3"), 3},
22+
{"unknown", 0},
23+
{nil, 0},
24+
}
25+
for _, tt := range tests {
26+
got := parseLooseInt(tt.input)
27+
if got != tt.want {
28+
t.Errorf("parseLooseInt(%v) = %d, want %d", tt.input, got, tt.want)
29+
}
30+
}
31+
}
32+
33+
func TestParseLooseCursorID(t *testing.T) {
34+
tests := []struct {
35+
name string
36+
in any
37+
want string
38+
ok bool
39+
}{
40+
{name: "string", in: "7648924766078847940", want: "7648924766078847940", ok: true},
41+
{name: "trim string", in: " 123 ", want: "123", ok: true},
42+
{name: "empty string", in: "", ok: false},
43+
{name: "zero string", in: "0", ok: false},
44+
{name: "json number", in: json.Number("123"), want: "123", ok: true},
45+
{name: "float safe integer", in: float64(123), want: "123", ok: true},
46+
{name: "float unsafe integer", in: float64(1<<53 + 1), ok: false},
47+
{name: "float fractional", in: float64(1.5), ok: false},
48+
{name: "negative", in: -1, ok: false},
49+
{name: "nil", in: nil, ok: false},
50+
}
51+
for _, tt := range tests {
52+
t.Run(tt.name, func(t *testing.T) {
53+
got, ok := parseLooseCursorID(tt.in)
54+
if got != tt.want || ok != tt.ok {
55+
t.Fatalf("parseLooseCursorID(%v) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.ok)
56+
}
57+
})
58+
}
59+
}
60+
61+
func TestExtractArtifactTokens(t *testing.T) {
62+
artifacts := []any{
63+
map[string]any{"doc_token": "main_doc", "artifact_type": float64(1)},
64+
map[string]any{"doc_token": "verbatim_doc", "artifact_type": float64(2)},
65+
map[string]any{"doc_token": "unknown_doc", "artifact_type": float64(99)},
66+
nil,
67+
}
68+
noteDoc, verbatimDoc := extractArtifactTokens(artifacts)
69+
if noteDoc != "main_doc" {
70+
t.Errorf("noteDoc = %q, want %q", noteDoc, "main_doc")
71+
}
72+
if verbatimDoc != "verbatim_doc" {
73+
t.Errorf("verbatimDoc = %q, want %q", verbatimDoc, "verbatim_doc")
74+
}
75+
}
76+
77+
func TestExtractArtifactTokens_Empty(t *testing.T) {
78+
noteDoc, verbatimDoc := extractArtifactTokens(nil)
79+
if noteDoc != "" || verbatimDoc != "" {
80+
t.Errorf("expected empty tokens for nil input, got %q, %q", noteDoc, verbatimDoc)
81+
}
82+
}
83+
84+
func TestExtractDocTokens(t *testing.T) {
85+
refs := []any{
86+
map[string]any{"doc_token": "shared1"},
87+
map[string]any{"doc_token": "shared2"},
88+
map[string]any{"doc_token": ""},
89+
map[string]any{},
90+
nil,
91+
}
92+
tokens := extractDocTokens(refs)
93+
if len(tokens) != 2 || tokens[0] != "shared1" || tokens[1] != "shared2" {
94+
t.Errorf("extractDocTokens = %v, want [shared1 shared2]", tokens)
95+
}
96+
}
97+
98+
func TestExtractDocTokens_Empty(t *testing.T) {
99+
tokens := extractDocTokens(nil)
100+
if tokens != nil {
101+
t.Errorf("expected nil for nil input, got %v", tokens)
102+
}
103+
}

0 commit comments

Comments
 (0)