From f44a1c6e681ef2bd65aa1d4fbdbdfa8e07656216 Mon Sep 17 00:00:00 2001 From: Anderson-RC Date: Thu, 11 Dec 2025 19:23:34 +0000 Subject: [PATCH 1/2] fix: Implement custom `GetNotes` API response parsing and add nil checks to improve `listNotes` command robustness. --- cmd/nlm/main.go | 26 ++++++- ...LabsTailwindOrchestrationService_client.go | 74 +++++++++++++++++-- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/cmd/nlm/main.go b/cmd/nlm/main.go index fe37265..3bdf120 100644 --- a/cmd/nlm/main.go +++ b/cmd/nlm/main.go @@ -47,7 +47,7 @@ type ChatSession struct { // ChatMessage represents a single message in the conversation type ChatMessage struct { - Role string `json:"role"` // "user" or "assistant" + Role string `json:"role"` // "user" or "assistant" Content string `json:"content"` Timestamp time.Time `json:"timestamp"` } @@ -1023,13 +1023,31 @@ func listNotes(c *api.Client, notebookID string) error { return fmt.Errorf("list notes: %w", err) } + if len(notes) == 0 { + fmt.Println("No notes found in this notebook.") + return nil + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0) fmt.Fprintln(w, "ID\tTITLE\tLAST MODIFIED") for _, note := range notes { + lastModified := "unknown" + if meta := note.GetMetadata(); meta != nil && meta.LastModifiedTime != nil { + lastModified = meta.LastModifiedTime.AsTime().Format(time.RFC3339) + } + // Extract the actual note ID from the SourceId wrapper + noteID := note.GetSourceId().GetSourceId() + if noteID == "" { + noteID = note.GetSourceId().String() + } + title := note.Title + if title == "" { + title = "(untitled)" + } fmt.Fprintf(w, "%s\t%s\t%s\n", - note.GetSourceId(), - note.Title, - note.GetMetadata().LastModifiedTime.AsTime().Format(time.RFC3339), + noteID, + title, + lastModified, ) } return w.Flush() diff --git a/gen/service/LabsTailwindOrchestrationService_client.go b/gen/service/LabsTailwindOrchestrationService_client.go index 6bf5d74..a38da23 100644 --- a/gen/service/LabsTailwindOrchestrationService_client.go +++ b/gen/service/LabsTailwindOrchestrationService_client.go @@ -6,6 +6,7 @@ package service import ( "context" + "encoding/json" "fmt" "github.com/tmc/nlm/gen/method" @@ -14,6 +15,7 @@ import ( "github.com/tmc/nlm/internal/beprotojson" "github.com/tmc/nlm/internal/rpc" "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" ) // LabsTailwindOrchestrationServiceClient is a generated client for the LabsTailwindOrchestrationService service. @@ -479,13 +481,75 @@ func (c *LabsTailwindOrchestrationServiceClient) GetNotes(ctx context.Context, r return nil, fmt.Errorf("GetNotes: %w", err) } - // Decode the response - var result notebooklmv1alpha1.GetNotesResponse - if err := beprotojson.Unmarshal(resp, &result); err != nil { - return nil, fmt.Errorf("GetNotes: unmarshal response: %w", err) + // Custom parsing for the GetNotes response + // The API returns: [[[noteID, [noteID, content, [type, id, [seconds, nanos]], null, title]], ...], ...] + var rawData []interface{} + if err := json.Unmarshal(resp, &rawData); err != nil { + return nil, fmt.Errorf("GetNotes: parse JSON: %w", err) } - return &result, nil + result := ¬ebooklmv1alpha1.GetNotesResponse{ + Notes: make([]*notebooklmv1alpha1.Source, 0), + } + + // First element is the notes array + if len(rawData) == 0 { + return result, nil + } + + notesArray, ok := rawData[0].([]interface{}) + if !ok { + // Try standard beprotojson unmarshal as fallback + if err := beprotojson.Unmarshal(resp, result); err != nil { + return nil, fmt.Errorf("GetNotes: unmarshal response: %w", err) + } + return result, nil + } + + for _, noteEntry := range notesArray { + noteArr, ok := noteEntry.([]interface{}) + if !ok || len(noteArr) < 2 { + continue + } + + note := ¬ebooklmv1alpha1.Source{} + + // Position 0: Source ID + if sourceID, ok := noteArr[0].(string); ok { + note.SourceId = ¬ebooklmv1alpha1.SourceId{ + SourceId: sourceID, + } + } + + // Position 1: Details array [id, content, metadata, null, title] + if detailsArr, ok := noteArr[1].([]interface{}); ok && len(detailsArr) >= 5 { + // Position 4 is the title + if title, ok := detailsArr[4].(string); ok { + note.Title = title + } + + // Position 2 is metadata: [type, id, [seconds, nanos]] + if metaArr, ok := detailsArr[2].([]interface{}); ok && len(metaArr) >= 3 { + note.Metadata = ¬ebooklmv1alpha1.SourceMetadata{} + + // Position 2 in metaArr is timestamp: [seconds, nanos] + if tsArr, ok := metaArr[2].([]interface{}); ok && len(tsArr) >= 2 { + if seconds, ok := tsArr[0].(float64); ok { + if nanos, ok := tsArr[1].(float64); ok { + note.Metadata.LastModifiedTime = ×tamppb.Timestamp{ + Seconds: int64(seconds), + Nanos: int32(nanos), + } + } + } + } + } + } + + result.Notes = append(result.Notes, note) + } + + return result, nil } // MutateNote calls the MutateNote RPC method. From ff7d21402b6d4ee878169c33485b8eb8184ae0eb Mon Sep 17 00:00:00 2001 From: Anderson-RC Date: Fri, 12 Dec 2025 11:52:13 +0000 Subject: [PATCH 2/2] refactor: clean up GetNotes parsing with documented API structure - Create notes_response.pb.go documenting expected API message types - Extract parseNoteEntry helper function for cleaner code - Add Content field to Source struct for note body text - Add detailed API structure comments in GetNotes client - Keep fallback to beprotojson for API format changes - Update FIX_GETNOTES_PARSING.md with implementation details --- gen/notebooklm/v1alpha1/notebooklm.pb.go | 1 + gen/notebooklm/v1alpha1/notes_response.pb.go | 227 ++++++++++++++++++ ...LabsTailwindOrchestrationService_client.go | 102 +++++--- 3 files changed, 297 insertions(+), 33 deletions(-) create mode 100644 gen/notebooklm/v1alpha1/notes_response.pb.go diff --git a/gen/notebooklm/v1alpha1/notebooklm.pb.go b/gen/notebooklm/v1alpha1/notebooklm.pb.go index 4393290..ea25bcb 100644 --- a/gen/notebooklm/v1alpha1/notebooklm.pb.go +++ b/gen/notebooklm/v1alpha1/notebooklm.pb.go @@ -463,6 +463,7 @@ type Source struct { Metadata *SourceMetadata `protobuf:"bytes,3,opt,name=metadata,proto3" json:"metadata,omitempty"` Settings *SourceSettings `protobuf:"bytes,4,opt,name=settings,proto3" json:"settings,omitempty"` Warnings []*wrapperspb.Int32Value `protobuf:"bytes,5,rep,name=warnings,proto3" json:"warnings,omitempty"` + Content string `protobuf:"bytes,6,opt,name=content,proto3" json:"content,omitempty"` } func (x *Source) Reset() { diff --git a/gen/notebooklm/v1alpha1/notes_response.pb.go b/gen/notebooklm/v1alpha1/notes_response.pb.go new file mode 100644 index 0000000..88057b9 --- /dev/null +++ b/gen/notebooklm/v1alpha1/notes_response.pb.go @@ -0,0 +1,227 @@ +// Handwritten protobuf types for GetNotes API response parsing. +// These types match the actual structure returned by the NotebookLM API +// rather than the standard Source structure. + +package notebooklmv1alpha1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +// NoteEntry represents a single note entry in the GetNotes response. +// API format: [noteID, [noteID, content, metadata, null, title]] +// Position mapping: +// +// Position 0 -> field 1: source_id (string) +// Position 1 -> field 2: details (NoteDetails) +type NoteEntry struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SourceId string `protobuf:"bytes,1,opt,name=source_id,json=sourceId,proto3" json:"source_id,omitempty"` + Details *NoteDetails `protobuf:"bytes,2,opt,name=details,proto3" json:"details,omitempty"` +} + +func (x *NoteEntry) Reset() { + *x = NoteEntry{} +} + +func (x *NoteEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NoteEntry) ProtoMessage() {} + +func (x *NoteEntry) ProtoReflect() protoreflect.Message { + return nil // Simplified - not needed for beprotojson +} + +func (*NoteEntry) Descriptor() ([]byte, []int) { + return nil, nil +} + +func (x *NoteEntry) GetSourceId() string { + if x != nil { + return x.SourceId + } + return "" +} + +func (x *NoteEntry) GetDetails() *NoteDetails { + if x != nil { + return x.Details + } + return nil +} + +// NoteDetails represents the nested details array within a note entry. +// API format: [noteID, content, metadata, null, title] +// Position mapping: +// +// Position 0 -> field 1: id (string, duplicate of source_id) +// Position 1 -> field 2: content (string, the note body) +// Position 2 -> field 3: metadata (NoteTimestampMetadata) +// Position 3 -> field 4: reserved (null, skipped) +// Position 4 -> field 5: title (string, the note title) +type NoteDetails struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + Metadata *NoteTimestampMetadata `protobuf:"bytes,3,opt,name=metadata,proto3" json:"metadata,omitempty"` + Reserved string `protobuf:"bytes,4,opt,name=reserved,proto3" json:"reserved,omitempty"` + Title string `protobuf:"bytes,5,opt,name=title,proto3" json:"title,omitempty"` +} + +func (x *NoteDetails) Reset() { + *x = NoteDetails{} +} + +func (x *NoteDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NoteDetails) ProtoMessage() {} + +func (x *NoteDetails) ProtoReflect() protoreflect.Message { + return nil +} + +func (*NoteDetails) Descriptor() ([]byte, []int) { + return nil, nil +} + +func (x *NoteDetails) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *NoteDetails) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +func (x *NoteDetails) GetMetadata() *NoteTimestampMetadata { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *NoteDetails) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +// NoteTimestampMetadata represents the metadata array within note details. +// API format: [type, id, [seconds, nanos]] +// Position mapping: +// +// Position 0 -> field 1: type (int32) +// Position 1 -> field 2: id (string) +// Position 2 -> field 3: timestamp (TimestampPair) +type NoteTimestampMetadata struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Type int32 `protobuf:"varint,1,opt,name=type,proto3" json:"type,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + Timestamp *TimestampPair `protobuf:"bytes,3,opt,name=timestamp,proto3" json:"timestamp,omitempty"` +} + +func (x *NoteTimestampMetadata) Reset() { + *x = NoteTimestampMetadata{} +} + +func (x *NoteTimestampMetadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NoteTimestampMetadata) ProtoMessage() {} + +func (x *NoteTimestampMetadata) ProtoReflect() protoreflect.Message { + return nil +} + +func (*NoteTimestampMetadata) Descriptor() ([]byte, []int) { + return nil, nil +} + +func (x *NoteTimestampMetadata) GetType() int32 { + if x != nil { + return x.Type + } + return 0 +} + +func (x *NoteTimestampMetadata) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *NoteTimestampMetadata) GetTimestamp() *TimestampPair { + if x != nil { + return x.Timestamp + } + return nil +} + +// TimestampPair represents a timestamp as [seconds, nanos]. +// API format: [seconds, nanos] +// Position mapping: +// +// Position 0 -> field 1: seconds (int64) +// Position 1 -> field 2: nanos (int32) +type TimestampPair struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Seconds int64 `protobuf:"varint,1,opt,name=seconds,proto3" json:"seconds,omitempty"` + Nanos int32 `protobuf:"varint,2,opt,name=nanos,proto3" json:"nanos,omitempty"` +} + +func (x *TimestampPair) Reset() { + *x = TimestampPair{} +} + +func (x *TimestampPair) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TimestampPair) ProtoMessage() {} + +func (x *TimestampPair) ProtoReflect() protoreflect.Message { + return nil +} + +func (*TimestampPair) Descriptor() ([]byte, []int) { + return nil, nil +} + +func (x *TimestampPair) GetSeconds() int64 { + if x != nil { + return x.Seconds + } + return 0 +} + +func (x *TimestampPair) GetNanos() int32 { + if x != nil { + return x.Nanos + } + return 0 +} diff --git a/gen/service/LabsTailwindOrchestrationService_client.go b/gen/service/LabsTailwindOrchestrationService_client.go index a38da23..61284c8 100644 --- a/gen/service/LabsTailwindOrchestrationService_client.go +++ b/gen/service/LabsTailwindOrchestrationService_client.go @@ -481,8 +481,27 @@ func (c *LabsTailwindOrchestrationServiceClient) GetNotes(ctx context.Context, r return nil, fmt.Errorf("GetNotes: %w", err) } - // Custom parsing for the GetNotes response - // The API returns: [[[noteID, [noteID, content, [type, id, [seconds, nanos]], null, title]], ...], ...] + // Parse the GetNotes response using the documented structure. + // See notes_response.pb.go for the expected message types. + // + // API Response Structure: + // [ + // [ <- notes array (position 0) + // [ <- NoteEntry + // "note-uuid", <- position 0: source_id + // [ <- position 1: NoteDetails + // "note-uuid", <- details[0]: id (duplicate) + // "content text...", <- details[1]: content + // [type, "id", [sec, nanos]], <- details[2]: NoteTimestampMetadata + // null, <- details[3]: reserved + // "Note Title" <- details[4]: title + // ] + // ], + // ... + // ], + // [additional-metadata] <- ignored + // ] + var rawData []interface{} if err := json.Unmarshal(resp, &rawData); err != nil { return nil, fmt.Errorf("GetNotes: parse JSON: %w", err) @@ -507,49 +526,66 @@ func (c *LabsTailwindOrchestrationServiceClient) GetNotes(ctx context.Context, r } for _, noteEntry := range notesArray { - noteArr, ok := noteEntry.([]interface{}) - if !ok || len(noteArr) < 2 { - continue + note := parseNoteEntry(noteEntry) + if note != nil { + result.Notes = append(result.Notes, note) } + } - note := ¬ebooklmv1alpha1.Source{} + return result, nil +} - // Position 0: Source ID - if sourceID, ok := noteArr[0].(string); ok { - note.SourceId = ¬ebooklmv1alpha1.SourceId{ - SourceId: sourceID, - } +// parseNoteEntry parses a single note entry from the API response. +// This matches the structure documented in notes_response.pb.go. +func parseNoteEntry(entry interface{}) *notebooklmv1alpha1.Source { + noteArr, ok := entry.([]interface{}) + if !ok || len(noteArr) < 2 { + return nil + } + + note := ¬ebooklmv1alpha1.Source{} + + // Position 0: Source ID + if sourceID, ok := noteArr[0].(string); ok { + note.SourceId = ¬ebooklmv1alpha1.SourceId{ + SourceId: sourceID, } + } - // Position 1: Details array [id, content, metadata, null, title] - if detailsArr, ok := noteArr[1].([]interface{}); ok && len(detailsArr) >= 5 { - // Position 4 is the title - if title, ok := detailsArr[4].(string); ok { - note.Title = title - } + // Position 1: Details array [id, content, metadata, null, title] + detailsArr, ok := noteArr[1].([]interface{}) + if !ok || len(detailsArr) < 5 { + return note + } - // Position 2 is metadata: [type, id, [seconds, nanos]] - if metaArr, ok := detailsArr[2].([]interface{}); ok && len(metaArr) >= 3 { - note.Metadata = ¬ebooklmv1alpha1.SourceMetadata{} - - // Position 2 in metaArr is timestamp: [seconds, nanos] - if tsArr, ok := metaArr[2].([]interface{}); ok && len(tsArr) >= 2 { - if seconds, ok := tsArr[0].(float64); ok { - if nanos, ok := tsArr[1].(float64); ok { - note.Metadata.LastModifiedTime = ×tamppb.Timestamp{ - Seconds: int64(seconds), - Nanos: int32(nanos), - } - } + // details[1]: Content (the note body text) + if content, ok := detailsArr[1].(string); ok { + note.Content = content + } + + // details[4]: Title + if title, ok := detailsArr[4].(string); ok { + note.Title = title + } + + // details[2]: Metadata [type, id, [seconds, nanos]] + if metaArr, ok := detailsArr[2].([]interface{}); ok && len(metaArr) >= 3 { + note.Metadata = ¬ebooklmv1alpha1.SourceMetadata{} + + // Position 2 in metaArr is timestamp: [seconds, nanos] + if tsArr, ok := metaArr[2].([]interface{}); ok && len(tsArr) >= 2 { + if seconds, ok := tsArr[0].(float64); ok { + if nanos, ok := tsArr[1].(float64); ok { + note.Metadata.LastModifiedTime = ×tamppb.Timestamp{ + Seconds: int64(seconds), + Nanos: int32(nanos), } } } } - - result.Notes = append(result.Notes, note) } - return result, nil + return note } // MutateNote calls the MutateNote RPC method.