···45454646// LocalImageResolver resolves local file paths to image metadata
4747type LocalImageResolver struct {
4848- // BlobUploader is called to upload image bytes and get a blob reference
4949- // If nil, creates a placeholder blob with a hash-based CID
5050- //
5151- // TODO: CLI commands that publish documents must provide this function to upload
5252- // images to AT Protocol blob storage via com.atproto.repo.uploadBlob
4848+ // Called to upload image bytes and get a blob reference
5349 BlobUploader func(data []byte, mimeType string) (Blob, error)
5450}
5551
+30-29
internal/services/atproto.go
···44// - Pull: Fetch pub.leaflet.document records from AT Protocol repository
55// - Post: Create new pub.leaflet.document records in AT Protocol repository
66// - Push: Update existing pub.leaflet.document records in AT Protocol repository
77-//
88-// Publishing Workflow (TODO):
99-// 1. Post - Create new document:
1010-// - Convert note to pub.leaflet.document format
1111-// - Upload any embedded images as blobs
1212-// - Create record with com.atproto.repo.createRecord
1313-// - Store returned rkey and cid in note metadata
1414-// 2. Push - Update existing document:
1515-// - Check if note has leaflet_rkey (indicates previously published)
1616-// - Convert updated note to pub.leaflet.document format
1717-// - Upload any new images as blobs
1818-// - Update record with com.atproto.repo.putRecord
1919-// - Update stored cid in note metadata
2020-// 3. Delete - Remove published document:
2121-// - Use com.atproto.repo.deleteRecord with stored rkey
2222-// - Clear leaflet metadata from note
2323-//
2424-// Blob Upload (TODO):
2525-// 1. Use com.atproto.repo.uploadBlob for images
2626-// 2. Returns blob reference with CID
2727-// 3. Include blob ref in ImageBlock structures
2828-//
2929-// Draft vs Published (TODO):
3030-// 1. Draft documents stored in collection: pub.leaflet.document.draft
3131-// 2. Published documents in: pub.leaflet.document
3232-// 3. Moving from draft to published requires:
3333-// - Delete from draft collection
3434-// - Create in published collection
3535-// - Update note metadata with new rkey
77+// - Delete: Remove pub.leaflet.document records from AT Protocol repository
368package services
3793810import (
···467439 }
468440469441 return nil
442442+}
443443+444444+// UploadBlob uploads binary data as a blob to AT Protocol
445445+func (s *ATProtoService) UploadBlob(ctx context.Context, data []byte, mimeType string) (public.Blob, error) {
446446+ if !s.IsAuthenticated() {
447447+ return public.Blob{}, fmt.Errorf("not authenticated")
448448+ }
449449+450450+ if len(data) == 0 {
451451+ return public.Blob{}, fmt.Errorf("data cannot be empty")
452452+ }
453453+454454+ if mimeType == "" {
455455+ return public.Blob{}, fmt.Errorf("mimeType is required")
456456+ }
457457+458458+ output, err := atproto.RepoUploadBlob(ctx, s.client, bytes.NewReader(data))
459459+ if err != nil {
460460+ return public.Blob{}, fmt.Errorf("failed to upload blob: %w", err)
461461+ }
462462+463463+ blob := public.Blob{
464464+ Type: public.TypeBlob,
465465+ Ref: public.CID{Link: output.Blob.Ref.String()},
466466+ MimeType: output.Blob.MimeType,
467467+ Size: int(output.Blob.Size),
468468+ }
469469+470470+ return blob, nil
470471}
471472472473// Close cleans up resources
+3-10
internal/ui/note_list_adapter.go
···4444}
45454646func (n *NoteRecord) GetDescription() string {
4747- // Create a short description from tags and modification time
4847 var parts []string
49485049 if len(n.Tags) > 0 {
···8079 repoOpts.Archived = &archived
8180 }
82818383- // Apply search filter if provided
8482 if opts.Search != "" {
8585- repoOpts.Content = opts.Search // Search in content
8383+ repoOpts.Content = opts.Search
8684 }
87858886 if opts.Limit > 0 {
···103101}
104102105103func (n *NoteDataSource) Count(ctx context.Context, opts ListOptions) (int, error) {
106106- // For simplicity, load all and count (could be optimized with a separate Count method)
107104 items, err := n.Load(ctx, opts)
108105 if err != nil {
109106 return 0, err
···112109}
113110114111func (n *NoteDataSource) Search(ctx context.Context, query string, opts ListOptions) ([]ListItem, error) {
115115- // Set search in options and use regular Load
116112 opts.Search = query
117113 return n.Load(ctx, opts)
118114}
···123119 opts.Title = "Notes"
124120 }
125121126126- // Enable search functionality for notes
127122 opts.ShowSearch = true
128123 opts.Searchable = true
129124130130- // Set up view handler for markdown rendering
131125 if opts.ViewHandler == nil {
132126 opts.ViewHandler = func(item ListItem) string {
133127 if noteRecord, ok := item.(*NoteRecord); ok {
···188182 }
189183 }
190184191191- // Render markdown
192185 renderer, err := glamour.NewTermRenderer(
193186 glamour.WithAutoStyle(),
194187 glamour.WithWordWrap(80),
195188 )
196189 if err != nil {
197197- return content.String() // Return unrendered if glamour fails
190190+ return content.String()
198191 }
199192200193 rendered, err := renderer.Render(content.String())
201194 if err != nil {
202202- return content.String() // Return unrendered if rendering fails
195195+ return content.String()
203196 }
204197205198 return rendered
+46-22
internal/ui/note_list_adapter_test.go
···11111212 "github.com/stormlightlabs/noteleaf/internal/models"
1313 "github.com/stormlightlabs/noteleaf/internal/repo"
1414+ "github.com/stormlightlabs/noteleaf/internal/shared"
1415)
15161617type mockNoteRepository struct {
···4243 }
4344 }
44454545- // Filter by content search
4646 if options.Content != "" && !strings.Contains(note.Content, options.Content) {
4747 continue
4848 }
···5757 return filtered, nil
5858}
59596060+func (m *mockNoteRepository) ListPublished(ctx context.Context) ([]*models.Note, error) {
6161+ if m.err != nil {
6262+ return nil, m.err
6363+ }
6464+ var published []*models.Note
6565+ for _, note := range m.notes {
6666+ if note.LeafletRKey != nil && !note.IsDraft {
6767+ published = append(published, note)
6868+ }
6969+ }
7070+ return published, nil
7171+}
7272+7373+func (m *mockNoteRepository) ListDrafts(ctx context.Context) ([]*models.Note, error) {
7474+ if m.err != nil {
7575+ return nil, m.err
7676+ }
7777+ var drafts []*models.Note
7878+ for _, note := range m.notes {
7979+ if note.LeafletRKey != nil && note.IsDraft {
8080+ drafts = append(drafts, note)
8181+ }
8282+ }
8383+ return drafts, nil
8484+}
8585+8686+func (m *mockNoteRepository) GetLeafletNotes(ctx context.Context) ([]*models.Note, error) {
8787+ if m.err != nil {
8888+ return nil, m.err
8989+ }
9090+ var leafletNotes []*models.Note
9191+ for _, note := range m.notes {
9292+ if note.LeafletRKey != nil {
9393+ leafletNotes = append(leafletNotes, note)
9494+ }
9595+ }
9696+ return leafletNotes, nil
9797+}
9898+6099func TestNoteAdapter(t *testing.T) {
61100 t.Run("NoteRecord", func(t *testing.T) {
62101 note := &models.Note{
···88127 for _, tt := range tests {
89128 t.Run(tt.name, func(t *testing.T) {
90129 result := record.GetField(tt.field)
9191- // For slices, do a deep comparison
92130 if tags, ok := tt.expected.([]string); ok {
93131 resultTags, ok := result.([]string)
94132 if !ok || len(resultTags) != len(tags) {
···182220 t.Errorf("Load() returned %d items, want 3", len(items))
183221 }
184222185185- // Check first item
186223 if items[0].GetTitle() != "Work Note" {
187224 t.Errorf("First item title = %q, want 'Work Note'", items[0].GetTitle())
188225 }
···323360 }
324361325362 outputStr := output.String()
326326- if !strings.Contains(outputStr, "Notes") {
327327- t.Error("Output should contain 'Notes' title")
328328- }
329329- if !strings.Contains(outputStr, "Test Note") {
330330- t.Error("Output should contain note title")
331331- }
363363+ shared.AssertContains(t, outputStr, "Notes", "Output should contain 'Notes' title")
364364+ shared.AssertContains(t, outputStr, "Test Note", "Output should contain note title")
332365 })
333366334367 t.Run("Format Note for View", func(t *testing.T) {
···344377345378 result := formatNoteForView(note)
346379347347- // Check that it contains expected elements
348348- if !strings.Contains(result, "Test Note") {
349349- t.Error("Formatted view should contain note title")
350350- }
351351- if !strings.Contains(result, "test") {
352352- t.Error("Formatted view should contain tags")
353353- }
354354- if !strings.Contains(result, "2023-01-01") {
355355- t.Error("Formatted view should contain created date")
356356- }
357357- if !strings.Contains(result, "2023-01-02") {
358358- t.Error("Formatted view should contain modified date")
359359- }
380380+ shared.AssertContains(t, result, "Test Note", "Formatted view should contain note title")
381381+ shared.AssertContains(t, result, "test", "Formatted view should contain tags")
382382+ shared.AssertContains(t, result, "2023-01-01", "Formatted view should contain created date")
383383+ shared.AssertContains(t, result, "2023-01-02", "Formatted view should contain modified date")
360384 })
361385}