diff --git a/cmd/nlm/main.go b/cmd/nlm/main.go index fe37265..b3b03bb 100644 --- a/cmd/nlm/main.go +++ b/cmd/nlm/main.go @@ -35,6 +35,7 @@ var ( chunkedResponse bool // Control rt=c parameter for chunked vs JSON array response useDirectRPC bool // Use direct RPC calls instead of orchestration service skipSources bool // Skip fetching sources for chat (useful when project is inaccessible) + full bool // Display full content (for notes, etc) ) // ChatSession represents a persistent chat conversation @@ -47,7 +48,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"` } @@ -64,6 +65,7 @@ func init() { flag.StringVar(&authToken, "auth", os.Getenv("NLM_AUTH_TOKEN"), "auth token (or set NLM_AUTH_TOKEN)") flag.StringVar(&cookies, "cookies", os.Getenv("NLM_COOKIES"), "cookies for authentication (or set NLM_COOKIES)") flag.StringVar(&mimeType, "mime", "", "specify MIME type for content (e.g. 'text/xml', 'application/json')") + flag.BoolVar(&full, "full", false, "display full content") flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage: nlm [arguments]\n\n") @@ -84,7 +86,10 @@ func init() { fmt.Fprintf(os.Stderr, " discover-sources Discover relevant sources\n\n") fmt.Fprintf(os.Stderr, "Note Commands:\n") - fmt.Fprintf(os.Stderr, " notes List notes in notebook\n") + fmt.Fprintf(os.Stderr, " notes [--full]\n") + fmt.Fprintf(os.Stderr, " List notes in notebook (use --full for content)\n") + fmt.Fprintf(os.Stderr, " read-note \n") + fmt.Fprintf(os.Stderr, " Display content of a specific note\n") fmt.Fprintf(os.Stderr, " new-note Create new note\n") fmt.Fprintf(os.Stderr, " update-note <id> <note-id> <content> <title> Edit note\n") fmt.Fprintf(os.Stderr, " rm-note <note-id> Remove note\n\n") @@ -397,8 +402,14 @@ func validateArgs(cmd string, args []string) error { return fmt.Errorf("invalid arguments") } case "notes": - if len(args) != 1 { - fmt.Fprintf(os.Stderr, "usage: nlm notes <notebook-id>\n") + // Allow 1 arg (notebook-id) or 2 args (notebook-id + --full flag) + if len(args) < 1 || len(args) > 2 { + fmt.Fprintf(os.Stderr, "usage: nlm notes <notebook-id> [--full]\n") + return fmt.Errorf("invalid arguments") + } + case "read-note": + if len(args) != 2 { + fmt.Fprintf(os.Stderr, "usage: nlm read-note <notebook-id> <note-id>\n") return fmt.Errorf("invalid arguments") } case "feedback": @@ -416,7 +427,7 @@ func isValidCommand(cmd string) bool { "help", "-h", "--help", "list", "ls", "create", "rm", "analytics", "list-featured", "sources", "add", "rm-source", "rename-source", "refresh-source", "check-source", "discover-sources", - "notes", "new-note", "update-note", "rm-note", + "notes", "read-note", "new-note", "update-note", "rm-note", "audio-create", "audio-get", "audio-rm", "audio-share", "audio-list", "audio-download", "video-create", "video-list", "video-download", "create-artifact", "get-artifact", "list-artifacts", "artifacts", "rename-artifact", "delete-artifact", "generate-guide", "generate-outline", "generate-section", "generate-magic", "generate-mindmap", "generate-chat", "chat", "chat-list", @@ -702,7 +713,25 @@ func runCmd(client *api.Client, cmd string, args ...string) error { // Note operations case "notes": - err = listNotes(client, args[0]) + // Handle --full flag if passed as argument + showFull := full + cleanArgs := []string{} + for _, arg := range args { + if arg == "--full" || arg == "-full" { + showFull = true + } else { + cleanArgs = append(cleanArgs, arg) + } + } + + if len(cleanArgs) != 1 { + fmt.Fprintf(os.Stderr, "usage: nlm notes <notebook-id> [--full]\n") + return fmt.Errorf("invalid arguments") + } + + err = listNotes(client, cleanArgs[0], showFull) + case "read-note": + err = readNote(client, args[0], args[1]) case "new-note": err = createNote(client, args[0], args[1]) case "update-note": @@ -1017,24 +1046,82 @@ func removeNote(c *api.Client, notebookID, noteID string) error { } // Note operations -func listNotes(c *api.Client, notebookID string) error { +func listNotes(c *api.Client, notebookID string, showFull bool) error { notes, err := c.GetNotes(notebookID) if err != nil { return fmt.Errorf("list notes: %w", err) } + if len(notes) == 0 { + fmt.Println("No notes found in this notebook.") + return nil + } + + if showFull { + for _, note := range notes { + currentID := note.GetSourceId().GetSourceId() + if currentID == "" { + currentID = note.GetSourceId().String() + } + fmt.Printf("Title: %s\n", note.Title) + fmt.Printf("ID: %s\n", currentID) + fmt.Printf("Last Modified: %s\n", note.GetMetadata().GetLastModifiedTime().AsTime().Format(time.RFC3339)) + fmt.Println("-------------------") + fmt.Println(note.Content) + fmt.Println("================================================================================") + } + 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() } +func readNote(c *api.Client, notebookID string, targetNoteID string) error { + notes, err := c.GetNotes(notebookID) + if err != nil { + return fmt.Errorf("read note: %w", err) + } + + for _, note := range notes { + // Check both direct ID and source ID wrapper + currentID := note.GetSourceId().GetSourceId() + if currentID == "" { + currentID = note.GetSourceId().String() + } + + if currentID == targetNoteID { + fmt.Printf("Title: %s\n", note.Title) + fmt.Printf("ID: %s\n", currentID) + fmt.Println("-------------------") + fmt.Println(note.Content) + return nil + } + } + return fmt.Errorf("note not found: %s", targetNoteID) +} + // Audio operations func getAudioOverview(c *api.Client, projectID string) error { fmt.Fprintf(os.Stderr, "Fetching audio overview...\n") diff --git a/cmd/nlm/testdata/note_commands.txt b/cmd/nlm/testdata/note_commands.txt index 7fe8c23..acbf9e9 100644 --- a/cmd/nlm/testdata/note_commands.txt +++ b/cmd/nlm/testdata/note_commands.txt @@ -4,12 +4,12 @@ # === NOTES COMMAND === # Test notes without arguments ! exec ./nlm_test notes -stderr 'usage: nlm notes <notebook-id>' +stderr 'usage: nlm notes' ! stderr 'panic' -# Test notes with too many arguments -! exec ./nlm_test notes notebook123 extra -stderr 'usage: nlm notes <notebook-id>' +# Test notes with --full flag (should require auth, not error on args) +! exec ./nlm_test notes notebook123 --full +stderr 'Authentication required' ! stderr 'panic' # Test notes without authentication @@ -17,6 +17,22 @@ stderr 'usage: nlm notes <notebook-id>' stderr 'Authentication required' ! stderr 'panic' +# === READ-NOTE COMMAND === +# Test read-note without arguments +! exec ./nlm_test read-note +stderr 'usage: nlm read-note' +! stderr 'panic' + +# Test read-note with only notebook ID +! exec ./nlm_test read-note notebook123 +stderr 'usage: nlm read-note' +! stderr 'panic' + +# Test read-note without authentication +! exec ./nlm_test read-note notebook123 note456 +stderr 'Authentication required' +! stderr 'panic' + # === NEW-NOTE COMMAND === # Test new-note without arguments ! exec ./nlm_test new-note 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 6bf5d74..61284c8 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,111 @@ 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) + // 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) } - 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 { + note := parseNoteEntry(noteEntry) + if note != nil { + result.Notes = append(result.Notes, note) + } + } + + return result, nil +} + +// 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] + detailsArr, ok := noteArr[1].([]interface{}) + if !ok || len(detailsArr) < 5 { + return note + } + + // 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), + } + } + } + } + } + + return note } // MutateNote calls the MutateNote RPC method. diff --git a/gen/service/notes_parser_test.go b/gen/service/notes_parser_test.go new file mode 100644 index 0000000..0f1a66d --- /dev/null +++ b/gen/service/notes_parser_test.go @@ -0,0 +1,209 @@ +package service + +import ( + "testing" + + notebooklmv1alpha1 "github.com/tmc/nlm/gen/notebooklm/v1alpha1" +) + +func TestParseNoteEntry(t *testing.T) { + tests := []struct { + name string + entry interface{} + wantNil bool + wantID string + wantTitle string + wantContent string + }{ + { + name: "nil entry returns nil", + entry: nil, + wantNil: true, + }, + { + name: "non-array entry returns nil", + entry: "not an array", + wantNil: true, + }, + { + name: "empty array returns nil", + entry: []interface{}{}, + wantNil: true, + }, + { + name: "single element array returns nil", + entry: []interface{}{"note-id"}, + wantNil: true, + }, + { + name: "valid entry with all fields", + entry: []interface{}{ + "note-uuid-123", + []interface{}{ + "note-uuid-123", // details[0]: id (duplicate) + "This is note content", // details[1]: content + []interface{}{ // details[2]: metadata + 1, + "meta-id", + []interface{}{float64(1733900000), float64(0)}, // timestamp + }, + nil, // details[3]: reserved + "Note Title", // details[4]: title + }, + }, + wantNil: false, + wantID: "note-uuid-123", + wantTitle: "Note Title", + wantContent: "This is note content", + }, + { + name: "entry with missing content", + entry: []interface{}{ + "note-uuid-456", + []interface{}{ + "note-uuid-456", + nil, // content is nil + []interface{}{1, "meta-id", []interface{}{float64(1733900000), float64(0)}}, + nil, + "Title Only", + }, + }, + wantNil: false, + wantID: "note-uuid-456", + wantTitle: "Title Only", + wantContent: "", // no content + }, + { + name: "entry with nil title", + entry: []interface{}{ + "note-uuid-789", + []interface{}{ + "note-uuid-789", + "Content here", + []interface{}{1, "meta-id", []interface{}{float64(1733900000), float64(0)}}, + nil, + nil, // title is nil + }, + }, + wantNil: false, + wantID: "note-uuid-789", + wantTitle: "", + wantContent: "Content here", + }, + { + name: "entry with short details array", + entry: []interface{}{ + "note-uuid-short", + []interface{}{"id", "content"}, // only 2 elements, not 5 + }, + wantNil: false, // returns Source with just ID populated + wantID: "note-uuid-short", + wantTitle: "", // can't extract title from short array + }, + { + name: "entry with malformed metadata (nil)", + entry: []interface{}{ + "note-uuid-nil-meta", + []interface{}{ + "note-uuid-nil-meta", + "Some content", + nil, // metadata is nil + nil, + "A Title", + }, + }, + wantNil: false, + wantID: "note-uuid-nil-meta", + wantTitle: "A Title", + wantContent: "Some content", + }, + { + name: "entry with non-string source ID", + entry: []interface{}{ + 12345, // not a string + []interface{}{ + "id", + "content", + []interface{}{1, "meta-id", []interface{}{float64(1733900000), float64(0)}}, + nil, + "Title", + }, + }, + wantNil: false, + wantID: "", // can't extract non-string ID + wantTitle: "Title", + wantContent: "content", // content is still parseable + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseNoteEntry(tt.entry) + + if tt.wantNil { + if got != nil { + t.Errorf("parseNoteEntry() = %v, want nil", got) + } + return + } + + if got == nil { + t.Errorf("parseNoteEntry() = nil, want non-nil") + return + } + + gotID := "" + if got.SourceId != nil { + gotID = got.SourceId.SourceId + } + if gotID != tt.wantID { + t.Errorf("parseNoteEntry().SourceId = %v, want %v", gotID, tt.wantID) + } + + if got.Title != tt.wantTitle { + t.Errorf("parseNoteEntry().Title = %v, want %v", got.Title, tt.wantTitle) + } + + if got.Content != tt.wantContent { + t.Errorf("parseNoteEntry().Content = %v, want %v", got.Content, tt.wantContent) + } + }) + } +} + +func TestParseNoteEntry_NilGuards(t *testing.T) { + // These tests specifically verify that malformed data doesn't cause panics + + testCases := []interface{}{ + nil, + "string", + 123, + []interface{}{}, + []interface{}{nil}, + []interface{}{nil, nil}, + []interface{}{"id", nil}, + []interface{}{"id", "not-an-array"}, + []interface{}{"id", []interface{}{}}, + []interface{}{"id", []interface{}{nil, nil, nil, nil, nil}}, + } + + for i, tc := range testCases { + t.Run(testName(i), func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("parseNoteEntry() panicked with input %v: %v", tc, r) + } + }() + + // Should not panic + _ = parseNoteEntry(tc) + }) + } +} + +func testName(i int) string { + return "malformed_input_" + string(rune('A'+i)) +} + +// Verify Source has Content field (compile-time check) +var _ = notebooklmv1alpha1.Source{Content: "test"}