···1011 "github.com/stormlightlabs/noteleaf/internal/handlers"
12 "github.com/stormlightlabs/noteleaf/internal/services"
013)
1415func setupCommandTest(t *testing.T) func() {
···475 if err == nil {
476 t.Error("expected movie add command to fail when search fails")
477 }
478- services.AssertErrorContains(t, err, "search failed")
479 })
480481 t.Run("remove command with non-existent movie ID", func(t *testing.T) {
···558 if err == nil {
559 t.Error("expected tv add command to fail when search fails")
560 }
561- services.AssertErrorContains(t, err, "tv search failed")
562 })
563564 t.Run("remove command with non-existent TV show ID", func(t *testing.T) {
···1011 "github.com/stormlightlabs/noteleaf/internal/handlers"
12 "github.com/stormlightlabs/noteleaf/internal/services"
13+ "github.com/stormlightlabs/noteleaf/internal/shared"
14)
1516func setupCommandTest(t *testing.T) func() {
···476 if err == nil {
477 t.Error("expected movie add command to fail when search fails")
478 }
479+ shared.AssertErrorContains(t, err, "search failed", "")
480 })
481482 t.Run("remove command with non-existent movie ID", func(t *testing.T) {
···559 if err == nil {
560 t.Error("expected tv add command to fail when search fails")
561 }
562+ shared.AssertErrorContains(t, err, "tv search failed", "")
563 })
564565 t.Run("remove command with non-existent TV show ID", func(t *testing.T) {
+10
internal/handlers/publication.go
···297 return fmt.Errorf("failed to get session: %w", err)
298 }
29900000300 converter := public.NewMarkdownConverter()
301 blocks, err := converter.ToLeaflet(note.Content)
302 if err != nil {
···373 return fmt.Errorf("failed to get session: %w", err)
374 }
37500000376 converter := public.NewMarkdownConverter()
377 blocks, err := converter.ToLeaflet(note.Content)
378 if err != nil {
···297 return fmt.Errorf("failed to get session: %w", err)
298 }
299300+ // TODO: Implement image handling for markdown conversion
301+ // 1. Extract note's directory from filepath/database
302+ // 2. Create LocalImageResolver with BlobUploader that calls h.atproto.UploadBlob()
303+ // 3. Use converter.WithImageResolver(resolver, noteDir) before ToLeaflet()
304+ // This will upload images to AT Protocol and get real CIDs/dimensions
305 converter := public.NewMarkdownConverter()
306 blocks, err := converter.ToLeaflet(note.Content)
307 if err != nil {
···378 return fmt.Errorf("failed to get session: %w", err)
379 }
380381+ // TODO: Implement image handling for markdown conversion (same as Post method)
382+ // 1. Extract note's directory from filepath/database
383+ // 2. Create LocalImageResolver with BlobUploader that calls h.atproto.UploadBlob()
384+ // 3. Use converter.WithImageResolver(resolver, noteDir) before ToLeaflet()
385+ // This will upload images to AT Protocol and get real CIDs/dimensions
386 converter := public.NewMarkdownConverter()
387 blocks, err := converter.ToLeaflet(note.Content)
388 if err != nil {
+234-27
internal/public/convert.go
···1// Package public provides conversion between markdown and leaflet block formats
2//
3-// TODO: Handle overlapping facets
4-// TODO: Implement image handling - requires blob resolution
005package public
67import (
8 "bytes"
9 "fmt"
00000010 "strings"
1112 "github.com/gomarkdown/markdown/ast"
···21 FromLeaflet(blocks []BlockWrap) (string, error)
22}
2300000000000000000000000000000000000000000000000000000000000024// MarkdownConverter implements the [Converter] interface
25type MarkdownConverter struct {
26- extensions parser.Extensions
0027}
2829type formatContext struct {
···37 return &MarkdownConverter{
38 extensions: extensions,
39 }
000000040}
4142// ToLeaflet converts markdown to leaflet blocks
43func (c *MarkdownConverter) ToLeaflet(markdown string) ([]BlockWrap, error) {
44 p := parser.NewWithExtensions(c.extensions)
45 doc := p.Parse([]byte(markdown))
000000000000000004647 var blocks []BlockWrap
4849 for _, child := range doc.GetChildren() {
50 switch n := child.(type) {
51 case *ast.Heading:
52- if block := c.convertHeading(n); block != nil {
53 blocks = append(blocks, *block)
54 }
55 case *ast.Paragraph:
56- if block := c.convertParagraph(n); block != nil {
57- blocks = append(blocks, *block)
58- }
59 case *ast.CodeBlock:
60 if block := c.convertCodeBlock(n); block != nil {
61 blocks = append(blocks, *block)
62 }
63 case *ast.BlockQuote:
64- if block := c.convertBlockquote(n); block != nil {
65 blocks = append(blocks, *block)
66 }
67 case *ast.List:
68- if block := c.convertList(n); block != nil {
69 blocks = append(blocks, *block)
70 }
71 case *ast.HorizontalRule:
···75 Type: TypeHorizontalRuleBlock,
76 },
77 })
000078 }
79 }
8081 return blocks, nil
82}
83000000000000000000084// convertHeading converts an AST heading to a leaflet HeaderBlock
85-func (c *MarkdownConverter) convertHeading(node *ast.Heading) *BlockWrap {
86- text, facets := c.extractTextAndFacets(node)
87 return &BlockWrap{
88 Type: TypeBlock,
89 Block: HeaderBlock{
···95 }
96}
9798-// convertParagraph converts an AST paragraph to a leaflet TextBlock
99-func (c *MarkdownConverter) convertParagraph(node *ast.Paragraph) *BlockWrap {
100- text, facets := c.extractTextAndFacets(node)
00000101 if strings.TrimSpace(text) == "" {
102 return nil
103 }
104105- return &BlockWrap{
106 Type: TypeBlock,
107 Block: TextBlock{
108 Type: TypeTextBlock,
109 Plaintext: text,
110 Facets: facets,
111 },
112- }
113}
114115// convertCodeBlock converts an AST code block to a leaflet CodeBlock
···126}
127128// convertBlockquote converts an AST blockquote to a leaflet BlockquoteBlock
129-func (c *MarkdownConverter) convertBlockquote(node *ast.BlockQuote) *BlockWrap {
130- text, facets := c.extractTextAndFacets(node)
131 return &BlockWrap{
132 Type: TypeBlock,
133 Block: BlockquoteBlock{
···139}
140141// convertList converts an AST list to a leaflet UnorderedListBlock
142-func (c *MarkdownConverter) convertList(node *ast.List) *BlockWrap {
143 var items []ListItem
144145 for _, child := range node.Children {
146 if listItem, ok := child.(*ast.ListItem); ok {
147- item := c.convertListItem(listItem)
148 if item != nil {
149 items = append(items, *item)
150 }
···161}
162163// convertListItem converts an AST list item to a leaflet ListItem
164-func (c *MarkdownConverter) convertListItem(node *ast.ListItem) *ListItem {
165- text, facets := c.extractTextAndFacets(node)
166 return &ListItem{
167 Type: TypeListItem,
168 Content: TextBlock{
···173 }
174}
175176-// extractTextAndFacets extracts plaintext and facets from an AST node
177-func (c *MarkdownConverter) extractTextAndFacets(node ast.Node) (string, []Facet) {
0000000000000000000000000000000000000000000000000178 var buf bytes.Buffer
179 var facets []Facet
0180 offset := 0
181182 var stack []formatContext
···189 buf.WriteString(content)
190191 if len(stack) > 0 {
192- ctx := stack[len(stack)-1]
000193 facet := Facet{
194 Type: TypeFacet,
195 Index: ByteSlice{
···197 ByteStart: offset,
198 ByteEnd: offset + len(content),
199 },
200- Features: ctx.features,
201 }
202 facets = append(facets, facet)
203 }
···269 stack = stack[:len(stack)-1]
270 }
271 }
00000000000000000000272 case *ast.Softbreak, *ast.Hardbreak:
273 if entering {
274 buf.WriteString(" ")
···277 }
278 return ast.GoToNext
279 })
280- return buf.String(), facets
0000000000000281}
282283// FromLeaflet converts leaflet blocks back to markdown
···1// Package public provides conversion between markdown and leaflet block formats
2//
3+// Image handling follows a two-pass approach:
4+// 1. Gather all image URLs from the markdown AST
5+// 2. Resolve images (fetch bytes, get dimensions, upload to blob storage)
6+// 3. Convert markdown to blocks using the resolved image metadata
7package public
89import (
10 "bytes"
11 "fmt"
12+ "image"
13+ _ "image/gif"
14+ _ "image/jpeg"
15+ _ "image/png"
16+ "os"
17+ "path/filepath"
18 "strings"
1920 "github.com/gomarkdown/markdown/ast"
···29 FromLeaflet(blocks []BlockWrap) (string, error)
30}
3132+// ImageInfo contains resolved image metadata
33+type ImageInfo struct {
34+ Blob Blob
35+ Width int
36+ Height int
37+}
38+39+// ImageResolver resolves image URLs to blob data and metadata
40+type ImageResolver interface {
41+ // ResolveImage resolves an image URL to blob data and dimensions
42+ // The url parameter may be a local file path or remote URL
43+ ResolveImage(url string) (*ImageInfo, error)
44+}
45+46+// LocalImageResolver resolves local file paths to image metadata
47+type LocalImageResolver struct {
48+ // BlobUploader is called to upload image bytes and get a blob reference
49+ // If nil, creates a placeholder blob with a hash-based CID
50+ //
51+ // TODO: CLI commands that publish documents must provide this function to upload
52+ // images to AT Protocol blob storage via com.atproto.repo.uploadBlob
53+ BlobUploader func(data []byte, mimeType string) (Blob, error)
54+}
55+56+// ResolveImage reads a local image file and extracts metadata
57+func (r *LocalImageResolver) ResolveImage(path string) (*ImageInfo, error) {
58+ data, err := os.ReadFile(path)
59+ if err != nil {
60+ return nil, fmt.Errorf("failed to read image: %w", err)
61+ }
62+63+ img, format, err := image.DecodeConfig(bytes.NewReader(data))
64+ if err != nil {
65+ return nil, fmt.Errorf("failed to decode image: %w", err)
66+ }
67+68+ mimeType := "image/" + format
69+70+ var blob Blob
71+ if r.BlobUploader != nil {
72+ blob, err = r.BlobUploader(data, mimeType)
73+ if err != nil {
74+ return nil, fmt.Errorf("failed to upload blob: %w", err)
75+ }
76+ } else {
77+ blob = Blob{
78+ Type: TypeBlob,
79+ Ref: CID{Link: "bafkreiplaceholder"},
80+ MimeType: mimeType,
81+ Size: len(data),
82+ }
83+ }
84+85+ return &ImageInfo{
86+ Blob: blob,
87+ Width: img.Width,
88+ Height: img.Height,
89+ }, nil
90+}
91+92// MarkdownConverter implements the [Converter] interface
93type MarkdownConverter struct {
94+ extensions parser.Extensions
95+ imageResolver ImageResolver
96+ basePath string // Base path for resolving relative image paths
97}
9899type formatContext struct {
···107 return &MarkdownConverter{
108 extensions: extensions,
109 }
110+}
111+112+// WithImageResolver sets an image resolver for the converter
113+func (c *MarkdownConverter) WithImageResolver(resolver ImageResolver, basePath string) *MarkdownConverter {
114+ c.imageResolver = resolver
115+ c.basePath = basePath
116+ return c
117}
118119// ToLeaflet converts markdown to leaflet blocks
120func (c *MarkdownConverter) ToLeaflet(markdown string) ([]BlockWrap, error) {
121 p := parser.NewWithExtensions(c.extensions)
122 doc := p.Parse([]byte(markdown))
123+ imageURLs := c.gatherImages(doc)
124+125+ resolvedImages := make(map[string]*ImageInfo)
126+ if c.imageResolver != nil {
127+ for _, url := range imageURLs {
128+ resolvedPath := url
129+ if !filepath.IsAbs(url) && c.basePath != "" {
130+ resolvedPath = filepath.Join(c.basePath, url)
131+ }
132+133+ info, err := c.imageResolver.ResolveImage(resolvedPath)
134+ if err != nil {
135+ return nil, fmt.Errorf("failed to resolve image %s: %w", url, err)
136+ }
137+ resolvedImages[url] = info
138+ }
139+ }
140141 var blocks []BlockWrap
142143 for _, child := range doc.GetChildren() {
144 switch n := child.(type) {
145 case *ast.Heading:
146+ if block := c.convertHeading(n, resolvedImages); block != nil {
147 blocks = append(blocks, *block)
148 }
149 case *ast.Paragraph:
150+ convertedBlocks := c.convertParagraph(n, resolvedImages)
151+ blocks = append(blocks, convertedBlocks...)
0152 case *ast.CodeBlock:
153 if block := c.convertCodeBlock(n); block != nil {
154 blocks = append(blocks, *block)
155 }
156 case *ast.BlockQuote:
157+ if block := c.convertBlockquote(n, resolvedImages); block != nil {
158 blocks = append(blocks, *block)
159 }
160 case *ast.List:
161+ if block := c.convertList(n, resolvedImages); block != nil {
162 blocks = append(blocks, *block)
163 }
164 case *ast.HorizontalRule:
···168 Type: TypeHorizontalRuleBlock,
169 },
170 })
171+ case *ast.Image:
172+ if block := c.convertImage(n, resolvedImages); block != nil {
173+ blocks = append(blocks, *block)
174+ }
175 }
176 }
177178 return blocks, nil
179}
180181+// gatherImages walks the AST and collects all image URLs
182+func (c *MarkdownConverter) gatherImages(node ast.Node) []string {
183+ var urls []string
184+185+ ast.WalkFunc(node, func(n ast.Node, entering bool) ast.WalkStatus {
186+ if !entering {
187+ return ast.GoToNext
188+ }
189+190+ if img, ok := n.(*ast.Image); ok {
191+ urls = append(urls, string(img.Destination))
192+ }
193+194+ return ast.GoToNext
195+ })
196+197+ return urls
198+}
199+200// convertHeading converts an AST heading to a leaflet HeaderBlock
201+func (c *MarkdownConverter) convertHeading(node *ast.Heading, resolvedImages map[string]*ImageInfo) *BlockWrap {
202+ text, facets, _ := c.extractTextAndFacets(node, resolvedImages)
203 return &BlockWrap{
204 Type: TypeBlock,
205 Block: HeaderBlock{
···211 }
212}
213214+// convertParagraph converts an AST paragraph to leaflet blocks
215+func (c *MarkdownConverter) convertParagraph(node *ast.Paragraph, resolvedImages map[string]*ImageInfo) []BlockWrap {
216+ text, facets, imageBlocks := c.extractTextAndFacets(node, resolvedImages)
217+218+ if len(imageBlocks) > 0 {
219+ return imageBlocks
220+ }
221+222 if strings.TrimSpace(text) == "" {
223 return nil
224 }
225226+ return []BlockWrap{{
227 Type: TypeBlock,
228 Block: TextBlock{
229 Type: TypeTextBlock,
230 Plaintext: text,
231 Facets: facets,
232 },
233+ }}
234}
235236// convertCodeBlock converts an AST code block to a leaflet CodeBlock
···247}
248249// convertBlockquote converts an AST blockquote to a leaflet BlockquoteBlock
250+func (c *MarkdownConverter) convertBlockquote(node *ast.BlockQuote, resolvedImages map[string]*ImageInfo) *BlockWrap {
251+ text, facets, _ := c.extractTextAndFacets(node, resolvedImages)
252 return &BlockWrap{
253 Type: TypeBlock,
254 Block: BlockquoteBlock{
···260}
261262// convertList converts an AST list to a leaflet UnorderedListBlock
263+func (c *MarkdownConverter) convertList(node *ast.List, resolvedImages map[string]*ImageInfo) *BlockWrap {
264 var items []ListItem
265266 for _, child := range node.Children {
267 if listItem, ok := child.(*ast.ListItem); ok {
268+ item := c.convertListItem(listItem, resolvedImages)
269 if item != nil {
270 items = append(items, *item)
271 }
···282}
283284// convertListItem converts an AST list item to a leaflet ListItem
285+func (c *MarkdownConverter) convertListItem(node *ast.ListItem, resolvedImages map[string]*ImageInfo) *ListItem {
286+ text, facets, _ := c.extractTextAndFacets(node, resolvedImages)
287 return &ListItem{
288 Type: TypeListItem,
289 Content: TextBlock{
···294 }
295}
296297+// convertImage converts an AST image to a leaflet ImageBlock
298+func (c *MarkdownConverter) convertImage(node *ast.Image, resolvedImages map[string]*ImageInfo) *BlockWrap {
299+ alt := string(node.Title)
300+ if alt == "" {
301+ for _, child := range node.Children {
302+ if text, ok := child.(*ast.Text); ok {
303+ alt = string(text.Literal)
304+ break
305+ }
306+ }
307+ }
308+309+ info, hasInfo := resolvedImages[string(node.Destination)]
310+311+ var blob Blob
312+ var aspectRatio AspectRatio
313+314+ if hasInfo {
315+ blob = info.Blob
316+ aspectRatio = AspectRatio{
317+ Type: TypeAspectRatio,
318+ Width: info.Width,
319+ Height: info.Height,
320+ }
321+ } else {
322+ blob = Blob{
323+ Type: TypeBlob,
324+ Ref: CID{Link: "bafkreiplaceholder"},
325+ MimeType: "image/jpeg",
326+ Size: 0,
327+ }
328+ aspectRatio = AspectRatio{
329+ Type: TypeAspectRatio,
330+ Width: 1,
331+ Height: 1,
332+ }
333+ }
334+335+ return &BlockWrap{
336+ Type: TypeBlock,
337+ Block: ImageBlock{
338+ Type: TypeImageBlock,
339+ Image: blob,
340+ Alt: alt,
341+ AspectRatio: aspectRatio,
342+ },
343+ }
344+}
345+346+// extractTextAndFacets extracts plaintext, facets, and image blocks from an AST node
347+func (c *MarkdownConverter) extractTextAndFacets(node ast.Node, resolvedImages map[string]*ImageInfo) (string, []Facet, []BlockWrap) {
348 var buf bytes.Buffer
349 var facets []Facet
350+ var blocks []BlockWrap
351 offset := 0
352353 var stack []formatContext
···360 buf.WriteString(content)
361362 if len(stack) > 0 {
363+ var allFeatures []FacetFeature
364+ for _, ctx := range stack {
365+ allFeatures = append(allFeatures, ctx.features...)
366+ }
367 facet := Facet{
368 Type: TypeFacet,
369 Index: ByteSlice{
···371 ByteStart: offset,
372 ByteEnd: offset + len(content),
373 },
374+ Features: allFeatures,
375 }
376 facets = append(facets, facet)
377 }
···443 stack = stack[:len(stack)-1]
444 }
445 }
446+ case *ast.Image:
447+ if entering {
448+ if buf.Len() > 0 {
449+ blocks = append(blocks, BlockWrap{
450+ Type: TypeBlock,
451+ Block: TextBlock{
452+ Type: TypeTextBlock,
453+ Plaintext: buf.String(),
454+ Facets: facets,
455+ },
456+ })
457+ buf.Reset()
458+ facets = nil
459+ offset = 0
460+ }
461+462+ if imgBlock := c.convertImage(v, resolvedImages); imgBlock != nil {
463+ blocks = append(blocks, *imgBlock)
464+ }
465+ }
466 case *ast.Softbreak, *ast.Hardbreak:
467 if entering {
468 buf.WriteString(" ")
···471 }
472 return ast.GoToNext
473 })
474+475+ // If we created blocks, add any remaining text
476+ if len(blocks) > 0 && buf.Len() > 0 {
477+ blocks = append(blocks, BlockWrap{
478+ Type: TypeBlock,
479+ Block: TextBlock{
480+ Type: TypeTextBlock,
481+ Plaintext: buf.String(),
482+ Facets: facets,
483+ },
484+ })
485+ }
486+487+ return buf.String(), facets, blocks
488}
489490// FromLeaflet converts leaflet blocks back to markdown
···117 t.Error("Search should return error for API failure")
118 }
119120- AssertErrorContains(t, err, "API returned status 500")
121 })
122123 t.Run("handles malformed JSON", func(t *testing.T) {
···135 t.Error("Search should return error for malformed JSON")
136 }
137138- AssertErrorContains(t, err, "failed to decode response")
139 })
140141 t.Run("handles context cancellation", func(t *testing.T) {
···231 t.Error("Get should return error for non-existent work")
232 }
233234- AssertErrorContains(t, err, "book not found")
235 })
236237 t.Run("handles API error", func(t *testing.T) {
···248 t.Error("Get should return error for API failure")
249 }
250251- AssertErrorContains(t, err, "API returned status 500")
252 })
253 })
254···299 t.Error("Check should return error for API failure")
300 }
301302- AssertErrorContains(t, err, "open Library API returned status 503")
303 })
304305 t.Run("handles network error", func(t *testing.T) {
···117 t.Error("Search should return error for API failure")
118 }
119120+ shared.AssertErrorContains(t, err, "API returned status 500", "")
121 })
122123 t.Run("handles malformed JSON", func(t *testing.T) {
···135 t.Error("Search should return error for malformed JSON")
136 }
137138+ shared.AssertErrorContains(t, err, "failed to decode response", "")
139 })
140141 t.Run("handles context cancellation", func(t *testing.T) {
···231 t.Error("Get should return error for non-existent work")
232 }
233234+ shared.AssertErrorContains(t, err, "book not found", "")
235 })
236237 t.Run("handles API error", func(t *testing.T) {
···248 t.Error("Get should return error for API failure")
249 }
250251+ shared.AssertErrorContains(t, err, "API returned status 500", "")
252 })
253 })
254···299 t.Error("Check should return error for API failure")
300 }
301302+ shared.AssertErrorContains(t, err, "open Library API returned status 503", "")
303 })
304305 t.Run("handles network error", func(t *testing.T) {
-12
internal/services/test_utilities.go
···220 t.Errorf("expected to find TV show containing '%s' in results", expectedTitle)
221}
222223-// AssertErrorContains checks that an error contains the expected message
224-func AssertErrorContains(t *testing.T, err error, expectedMsg string) {
225- t.Helper()
226-227- if err == nil {
228- t.Fatalf("expected error containing '%s', got nil", expectedMsg)
229- }
230- if !strings.Contains(err.Error(), expectedMsg) {
231- t.Errorf("expected error to contain '%s', got '%v'", expectedMsg, err)
232- }
233-}
234-235// CreateMovieService returns a new movie service for testing
236func CreateMovieService() *MovieService {
237 return NewMovieService()
···220 t.Errorf("expected to find TV show containing '%s' in results", expectedTitle)
221}
222000000000000223// CreateMovieService returns a new movie service for testing
224func CreateMovieService() *MovieService {
225 return NewMovieService()