cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat(pub): implement image handling in markdown conversion with tests

* replace AssertErrorContains with shared implementation across services and tests

+594 -52
+3 -2
cmd/commands_test.go
··· 10 10 11 11 "github.com/stormlightlabs/noteleaf/internal/handlers" 12 12 "github.com/stormlightlabs/noteleaf/internal/services" 13 + "github.com/stormlightlabs/noteleaf/internal/shared" 13 14 ) 14 15 15 16 func setupCommandTest(t *testing.T) func() { ··· 475 476 if err == nil { 476 477 t.Error("expected movie add command to fail when search fails") 477 478 } 478 - services.AssertErrorContains(t, err, "search failed") 479 + shared.AssertErrorContains(t, err, "search failed", "") 479 480 }) 480 481 481 482 t.Run("remove command with non-existent movie ID", func(t *testing.T) { ··· 558 559 if err == nil { 559 560 t.Error("expected tv add command to fail when search fails") 560 561 } 561 - services.AssertErrorContains(t, err, "tv search failed") 562 + shared.AssertErrorContains(t, err, "tv search failed", "") 562 563 }) 563 564 564 565 t.Run("remove command with non-existent TV show ID", func(t *testing.T) {
+10
internal/handlers/publication.go
··· 297 297 return fmt.Errorf("failed to get session: %w", err) 298 298 } 299 299 300 + // 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 300 305 converter := public.NewMarkdownConverter() 301 306 blocks, err := converter.ToLeaflet(note.Content) 302 307 if err != nil { ··· 373 378 return fmt.Errorf("failed to get session: %w", err) 374 379 } 375 380 381 + // 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 376 386 converter := public.NewMarkdownConverter() 377 387 blocks, err := converter.ToLeaflet(note.Content) 378 388 if err != nil {
+234 -27
internal/public/convert.go
··· 1 1 // Package public provides conversion between markdown and leaflet block formats 2 2 // 3 - // TODO: Handle overlapping facets 4 - // TODO: Implement image handling - requires blob resolution 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 5 7 package public 6 8 7 9 import ( 8 10 "bytes" 9 11 "fmt" 12 + "image" 13 + _ "image/gif" 14 + _ "image/jpeg" 15 + _ "image/png" 16 + "os" 17 + "path/filepath" 10 18 "strings" 11 19 12 20 "github.com/gomarkdown/markdown/ast" ··· 21 29 FromLeaflet(blocks []BlockWrap) (string, error) 22 30 } 23 31 32 + // 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 + 24 92 // MarkdownConverter implements the [Converter] interface 25 93 type MarkdownConverter struct { 26 - extensions parser.Extensions 94 + extensions parser.Extensions 95 + imageResolver ImageResolver 96 + basePath string // Base path for resolving relative image paths 27 97 } 28 98 29 99 type formatContext struct { ··· 37 107 return &MarkdownConverter{ 38 108 extensions: extensions, 39 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 40 117 } 41 118 42 119 // ToLeaflet converts markdown to leaflet blocks 43 120 func (c *MarkdownConverter) ToLeaflet(markdown string) ([]BlockWrap, error) { 44 121 p := parser.NewWithExtensions(c.extensions) 45 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 + } 46 140 47 141 var blocks []BlockWrap 48 142 49 143 for _, child := range doc.GetChildren() { 50 144 switch n := child.(type) { 51 145 case *ast.Heading: 52 - if block := c.convertHeading(n); block != nil { 146 + if block := c.convertHeading(n, resolvedImages); block != nil { 53 147 blocks = append(blocks, *block) 54 148 } 55 149 case *ast.Paragraph: 56 - if block := c.convertParagraph(n); block != nil { 57 - blocks = append(blocks, *block) 58 - } 150 + convertedBlocks := c.convertParagraph(n, resolvedImages) 151 + blocks = append(blocks, convertedBlocks...) 59 152 case *ast.CodeBlock: 60 153 if block := c.convertCodeBlock(n); block != nil { 61 154 blocks = append(blocks, *block) 62 155 } 63 156 case *ast.BlockQuote: 64 - if block := c.convertBlockquote(n); block != nil { 157 + if block := c.convertBlockquote(n, resolvedImages); block != nil { 65 158 blocks = append(blocks, *block) 66 159 } 67 160 case *ast.List: 68 - if block := c.convertList(n); block != nil { 161 + if block := c.convertList(n, resolvedImages); block != nil { 69 162 blocks = append(blocks, *block) 70 163 } 71 164 case *ast.HorizontalRule: ··· 75 168 Type: TypeHorizontalRuleBlock, 76 169 }, 77 170 }) 171 + case *ast.Image: 172 + if block := c.convertImage(n, resolvedImages); block != nil { 173 + blocks = append(blocks, *block) 174 + } 78 175 } 79 176 } 80 177 81 178 return blocks, nil 82 179 } 83 180 181 + // 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 + 84 200 // convertHeading converts an AST heading to a leaflet HeaderBlock 85 - func (c *MarkdownConverter) convertHeading(node *ast.Heading) *BlockWrap { 86 - text, facets := c.extractTextAndFacets(node) 201 + func (c *MarkdownConverter) convertHeading(node *ast.Heading, resolvedImages map[string]*ImageInfo) *BlockWrap { 202 + text, facets, _ := c.extractTextAndFacets(node, resolvedImages) 87 203 return &BlockWrap{ 88 204 Type: TypeBlock, 89 205 Block: HeaderBlock{ ··· 95 211 } 96 212 } 97 213 98 - // convertParagraph converts an AST paragraph to a leaflet TextBlock 99 - func (c *MarkdownConverter) convertParagraph(node *ast.Paragraph) *BlockWrap { 100 - text, facets := c.extractTextAndFacets(node) 214 + // 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 + 101 222 if strings.TrimSpace(text) == "" { 102 223 return nil 103 224 } 104 225 105 - return &BlockWrap{ 226 + return []BlockWrap{{ 106 227 Type: TypeBlock, 107 228 Block: TextBlock{ 108 229 Type: TypeTextBlock, 109 230 Plaintext: text, 110 231 Facets: facets, 111 232 }, 112 - } 233 + }} 113 234 } 114 235 115 236 // convertCodeBlock converts an AST code block to a leaflet CodeBlock ··· 126 247 } 127 248 128 249 // convertBlockquote converts an AST blockquote to a leaflet BlockquoteBlock 129 - func (c *MarkdownConverter) convertBlockquote(node *ast.BlockQuote) *BlockWrap { 130 - text, facets := c.extractTextAndFacets(node) 250 + func (c *MarkdownConverter) convertBlockquote(node *ast.BlockQuote, resolvedImages map[string]*ImageInfo) *BlockWrap { 251 + text, facets, _ := c.extractTextAndFacets(node, resolvedImages) 131 252 return &BlockWrap{ 132 253 Type: TypeBlock, 133 254 Block: BlockquoteBlock{ ··· 139 260 } 140 261 141 262 // convertList converts an AST list to a leaflet UnorderedListBlock 142 - func (c *MarkdownConverter) convertList(node *ast.List) *BlockWrap { 263 + func (c *MarkdownConverter) convertList(node *ast.List, resolvedImages map[string]*ImageInfo) *BlockWrap { 143 264 var items []ListItem 144 265 145 266 for _, child := range node.Children { 146 267 if listItem, ok := child.(*ast.ListItem); ok { 147 - item := c.convertListItem(listItem) 268 + item := c.convertListItem(listItem, resolvedImages) 148 269 if item != nil { 149 270 items = append(items, *item) 150 271 } ··· 161 282 } 162 283 163 284 // 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) 285 + func (c *MarkdownConverter) convertListItem(node *ast.ListItem, resolvedImages map[string]*ImageInfo) *ListItem { 286 + text, facets, _ := c.extractTextAndFacets(node, resolvedImages) 166 287 return &ListItem{ 167 288 Type: TypeListItem, 168 289 Content: TextBlock{ ··· 173 294 } 174 295 } 175 296 176 - // extractTextAndFacets extracts plaintext and facets from an AST node 177 - func (c *MarkdownConverter) extractTextAndFacets(node ast.Node) (string, []Facet) { 297 + // 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) { 178 348 var buf bytes.Buffer 179 349 var facets []Facet 350 + var blocks []BlockWrap 180 351 offset := 0 181 352 182 353 var stack []formatContext ··· 189 360 buf.WriteString(content) 190 361 191 362 if len(stack) > 0 { 192 - ctx := stack[len(stack)-1] 363 + var allFeatures []FacetFeature 364 + for _, ctx := range stack { 365 + allFeatures = append(allFeatures, ctx.features...) 366 + } 193 367 facet := Facet{ 194 368 Type: TypeFacet, 195 369 Index: ByteSlice{ ··· 197 371 ByteStart: offset, 198 372 ByteEnd: offset + len(content), 199 373 }, 200 - Features: ctx.features, 374 + Features: allFeatures, 201 375 } 202 376 facets = append(facets, facet) 203 377 } ··· 269 443 stack = stack[:len(stack)-1] 270 444 } 271 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 + } 272 466 case *ast.Softbreak, *ast.Hardbreak: 273 467 if entering { 274 468 buf.WriteString(" ") ··· 277 471 } 278 472 return ast.GoToNext 279 473 }) 280 - return buf.String(), facets 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 281 488 } 282 489 283 490 // FromLeaflet converts leaflet blocks back to markdown
+335
internal/public/convert_test.go
··· 1 1 package public 2 2 3 3 import ( 4 + "image" 5 + "image/png" 6 + "os" 7 + "path/filepath" 4 8 "strings" 5 9 "testing" 6 10 ··· 198 202 199 203 shared.AssertTrue(t, len(text.Facets) >= 3, "should have at least 3 facets") 200 204 }) 205 + 206 + t.Run("handles overlapping bold and italic", func(t *testing.T) { 207 + markdown := "***bold and italic***" 208 + blocks, err := converter.ToLeaflet(markdown) 209 + 210 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 211 + text := blocks[0].Block.(TextBlock) 212 + 213 + shared.AssertEqual(t, "bold and italic", text.Plaintext, "text should be correct") 214 + shared.AssertTrue(t, len(text.Facets) > 0, "should have facets") 215 + 216 + facet := text.Facets[0] 217 + shared.AssertEqual(t, 2, len(facet.Features), "should have 2 features") 218 + 219 + hasBold := false 220 + hasItalic := false 221 + for _, feature := range facet.Features { 222 + switch feature.(type) { 223 + case FacetBold: 224 + hasBold = true 225 + case FacetItalic: 226 + hasItalic = true 227 + } 228 + } 229 + shared.AssertTrue(t, hasBold, "should have bold feature") 230 + shared.AssertTrue(t, hasItalic, "should have italic feature") 231 + }) 232 + 233 + t.Run("handles nested bold in italic", func(t *testing.T) { 234 + markdown := "*italic **and bold** still italic*" 235 + blocks, err := converter.ToLeaflet(markdown) 236 + 237 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 238 + text := blocks[0].Block.(TextBlock) 239 + 240 + shared.AssertEqual(t, "italic and bold still italic", text.Plaintext, "text should be correct") 241 + shared.AssertTrue(t, len(text.Facets) >= 2, "should have multiple facets") 242 + 243 + foundOverlap := false 244 + for _, facet := range text.Facets { 245 + if strings.Contains(text.Plaintext[facet.Index.ByteStart:facet.Index.ByteEnd], "and bold") { 246 + shared.AssertTrue(t, len(facet.Features) >= 2, "overlapping section should have multiple features") 247 + foundOverlap = true 248 + } 249 + } 250 + shared.AssertTrue(t, foundOverlap, "should find overlapping facet") 251 + }) 252 + 253 + t.Run("handles link with formatting", func(t *testing.T) { 254 + markdown := "[**bold link**](https://example.com)" 255 + blocks, err := converter.ToLeaflet(markdown) 256 + 257 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 258 + text := blocks[0].Block.(TextBlock) 259 + 260 + shared.AssertEqual(t, "bold link", text.Plaintext, "text should be correct") 261 + shared.AssertTrue(t, len(text.Facets) > 0, "should have facets") 262 + 263 + hasLink := false 264 + hasBold := false 265 + for _, facet := range text.Facets { 266 + for _, feature := range facet.Features { 267 + switch f := feature.(type) { 268 + case FacetLink: 269 + hasLink = true 270 + shared.AssertEqual(t, "https://example.com", f.URI, "link URI should match") 271 + case FacetBold: 272 + hasBold = true 273 + } 274 + } 275 + } 276 + shared.AssertTrue(t, hasLink, "should have link feature") 277 + shared.AssertTrue(t, hasBold, "should have bold feature") 278 + }) 279 + 280 + t.Run("handles strikethrough with bold", func(t *testing.T) { 281 + markdown := "~~**deleted bold**~~" 282 + blocks, err := converter.ToLeaflet(markdown) 283 + 284 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 285 + text := blocks[0].Block.(TextBlock) 286 + 287 + shared.AssertEqual(t, "deleted bold", text.Plaintext, "text should be correct") 288 + shared.AssertTrue(t, len(text.Facets) > 0, "should have facets") 289 + 290 + hasStrike := false 291 + hasBold := false 292 + for _, facet := range text.Facets { 293 + for _, feature := range facet.Features { 294 + switch feature.(type) { 295 + case FacetStrikethrough: 296 + hasStrike = true 297 + case FacetBold: 298 + hasBold = true 299 + } 300 + } 301 + } 302 + shared.AssertTrue(t, hasStrike, "should have strikethrough feature") 303 + shared.AssertTrue(t, hasBold, "should have bold feature") 304 + }) 305 + 306 + t.Run("handles complex nested formatting", func(t *testing.T) { 307 + markdown := "*italic **bold and italic** italic*" 308 + blocks, err := converter.ToLeaflet(markdown) 309 + 310 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 311 + text := blocks[0].Block.(TextBlock) 312 + 313 + foundBoldItalic := false 314 + for _, facet := range text.Facets { 315 + content := text.Plaintext[facet.Index.ByteStart:facet.Index.ByteEnd] 316 + if strings.Contains(content, "bold and italic") { 317 + shared.AssertTrue(t, len(facet.Features) >= 2, "nested section should have multiple features") 318 + foundBoldItalic = true 319 + } 320 + } 321 + shared.AssertTrue(t, foundBoldItalic, "should find nested bold and italic section") 322 + }) 201 323 }) 202 324 203 325 t.Run("Round-trip Conversion", func(t *testing.T) { ··· 277 399 blocks, err := converter.ToLeaflet(markdown) 278 400 shared.AssertNoError(t, err, "should succeed") 279 401 shared.AssertEqual(t, 2, len(blocks), "should have 2 blocks") 402 + }) 403 + }) 404 + 405 + t.Run("Image Handling", func(t *testing.T) { 406 + tmpDir := t.TempDir() 407 + createTestImage := func(t *testing.T, name string, width, height int) string { 408 + path := filepath.Join(tmpDir, name) 409 + 410 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 411 + f, err := os.Create(path) 412 + shared.AssertNoError(t, err, "should create image file") 413 + defer f.Close() 414 + 415 + err = png.Encode(f, img) 416 + shared.AssertNoError(t, err, "should encode image") 417 + 418 + return path 419 + } 420 + 421 + t.Run("converts image without resolver (placeholder)", func(t *testing.T) { 422 + markdown := "![alt text](image.png)" 423 + converter := NewMarkdownConverter() 424 + blocks, err := converter.ToLeaflet(markdown) 425 + 426 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 427 + shared.AssertTrue(t, len(blocks) >= 1, "should have at least 1 block") 428 + 429 + var imgBlock ImageBlock 430 + found := false 431 + for _, block := range blocks { 432 + if img, ok := block.Block.(ImageBlock); ok { 433 + imgBlock = img 434 + found = true 435 + break 436 + } 437 + } 438 + 439 + shared.AssertTrue(t, found, "should find image block") 440 + shared.AssertEqual(t, TypeImageBlock, imgBlock.Type, "type should match") 441 + shared.AssertEqual(t, "alt text", imgBlock.Alt, "alt text should match") 442 + shared.AssertEqual(t, "bafkreiplaceholder", imgBlock.Image.Ref.Link, "should have placeholder CID") 443 + }) 444 + 445 + t.Run("resolves local image with dimensions", func(t *testing.T) { 446 + _ = createTestImage(t, "test.png", 800, 600) 447 + markdown := "![test image](test.png)" 448 + 449 + resolver := &LocalImageResolver{} 450 + converter := NewMarkdownConverter().WithImageResolver(resolver, tmpDir) 451 + 452 + blocks, err := converter.ToLeaflet(markdown) 453 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 454 + shared.AssertTrue(t, len(blocks) >= 1, "should have at least 1 block") 455 + 456 + var imgBlock ImageBlock 457 + found := false 458 + for _, block := range blocks { 459 + if img, ok := block.Block.(ImageBlock); ok { 460 + imgBlock = img 461 + found = true 462 + break 463 + } 464 + } 465 + 466 + shared.AssertTrue(t, found, "should find image block") 467 + shared.AssertEqual(t, "test image", imgBlock.Alt, "alt text should match") 468 + shared.AssertEqual(t, 800, imgBlock.AspectRatio.Width, "width should match") 469 + shared.AssertEqual(t, 600, imgBlock.AspectRatio.Height, "height should match") 470 + shared.AssertEqual(t, "image/png", imgBlock.Image.MimeType, "mime type should match") 471 + }) 472 + 473 + t.Run("handles inline images in paragraph", func(t *testing.T) { 474 + _ = createTestImage(t, "inline.png", 100, 100) 475 + markdown := "Some text before ![inline](inline.png) and text after" 476 + 477 + resolver := &LocalImageResolver{} 478 + converter := NewMarkdownConverter().WithImageResolver(resolver, tmpDir) 479 + 480 + blocks, err := converter.ToLeaflet(markdown) 481 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 482 + shared.AssertTrue(t, len(blocks) >= 2, "should have multiple blocks for inline images") 483 + 484 + textBlock1, ok := blocks[0].Block.(TextBlock) 485 + shared.AssertTrue(t, ok, "first block should be text") 486 + shared.AssertTrue(t, strings.Contains(textBlock1.Plaintext, "Some text before"), "should contain text before image") 487 + 488 + imgBlock, ok := blocks[1].Block.(ImageBlock) 489 + shared.AssertTrue(t, ok, "second block should be image") 490 + shared.AssertEqual(t, "inline", imgBlock.Alt, "alt text should match") 491 + }) 492 + 493 + t.Run("handles multiple images", func(t *testing.T) { 494 + _ = createTestImage(t, "img1.png", 200, 150) 495 + _ = createTestImage(t, "img2.png", 300, 200) 496 + markdown := "![first](img1.png)\n\n![second](img2.png)" 497 + 498 + resolver := &LocalImageResolver{} 499 + converter := NewMarkdownConverter().WithImageResolver(resolver, tmpDir) 500 + 501 + blocks, err := converter.ToLeaflet(markdown) 502 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 503 + 504 + var imageBlocks []ImageBlock 505 + for _, block := range blocks { 506 + if img, ok := block.Block.(ImageBlock); ok { 507 + imageBlocks = append(imageBlocks, img) 508 + } 509 + } 510 + 511 + shared.AssertEqual(t, 2, len(imageBlocks), "should have 2 image blocks") 512 + shared.AssertEqual(t, "first", imageBlocks[0].Alt, "first alt text") 513 + shared.AssertEqual(t, 200, imageBlocks[0].AspectRatio.Width, "first width") 514 + shared.AssertEqual(t, "second", imageBlocks[1].Alt, "second alt text") 515 + shared.AssertEqual(t, 300, imageBlocks[1].AspectRatio.Width, "second width") 516 + }) 517 + 518 + t.Run("uses custom blob uploader", func(t *testing.T) { 519 + _ = createTestImage(t, "upload.png", 100, 100) 520 + markdown := "![uploaded](upload.png)" 521 + 522 + uploadCalled := false 523 + resolver := &LocalImageResolver{ 524 + BlobUploader: func(data []byte, mimeType string) (Blob, error) { 525 + uploadCalled = true 526 + shared.AssertEqual(t, "image/png", mimeType, "mime type should be png") 527 + shared.AssertTrue(t, len(data) > 0, "should have data") 528 + 529 + return Blob{ 530 + Type: TypeBlob, 531 + Ref: CID{Link: "bafkreicustomcid"}, 532 + MimeType: mimeType, 533 + Size: len(data), 534 + }, nil 535 + }, 536 + } 537 + 538 + converter := NewMarkdownConverter().WithImageResolver(resolver, tmpDir) 539 + blocks, err := converter.ToLeaflet(markdown) 540 + 541 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 542 + shared.AssertTrue(t, uploadCalled, "upload should be called") 543 + 544 + imgBlock, ok := blocks[0].Block.(ImageBlock) 545 + shared.AssertTrue(t, ok, "block should be image") 546 + shared.AssertEqual(t, "bafkreicustomcid", imgBlock.Image.Ref.Link, "should use custom CID") 547 + }) 548 + 549 + t.Run("handles missing image gracefully", func(t *testing.T) { 550 + markdown := "![missing](nonexistent.png)" 551 + resolver := &LocalImageResolver{} 552 + converter := NewMarkdownConverter().WithImageResolver(resolver, tmpDir) 553 + 554 + _, err := converter.ToLeaflet(markdown) 555 + shared.AssertError(t, err, "should error on missing image") 556 + shared.AssertTrue(t, strings.Contains(err.Error(), "failed to resolve image"), "error should mention resolution failure") 557 + }) 558 + 559 + t.Run("gathers images from complex document", func(t *testing.T) { 560 + _ = createTestImage(t, "header.png", 100, 100) 561 + _ = createTestImage(t, "body.png", 200, 200) 562 + _ = createTestImage(t, "list.png", 50, 50) 563 + 564 + markdown := `# Header 565 + 566 + ![header image](header.png) 567 + 568 + Some text with ![inline](body.png) image. 569 + 570 + - List item 571 + - Another item with ![list img](list.png) 572 + ` 573 + 574 + resolver := &LocalImageResolver{} 575 + converter := NewMarkdownConverter().WithImageResolver(resolver, tmpDir) 576 + 577 + blocks, err := converter.ToLeaflet(markdown) 578 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 579 + 580 + imageCount := 0 581 + for _, block := range blocks { 582 + if _, ok := block.Block.(ImageBlock); ok { 583 + imageCount++ 584 + } 585 + } 586 + shared.AssertTrue(t, imageCount >= 2, "should find multiple images") 587 + }) 588 + 589 + t.Run("preserves image dimensions accurately", func(t *testing.T) { 590 + testCases := []struct { 591 + name string 592 + width int 593 + height int 594 + }{ 595 + {"square.png", 100, 100}, 596 + {"landscape.png", 1920, 1080}, 597 + {"portrait.png", 1080, 1920}, 598 + {"wide.png", 2560, 1440}, 599 + } 600 + 601 + for _, tc := range testCases { 602 + createTestImage(t, tc.name, tc.width, tc.height) 603 + markdown := "![test](" + tc.name + ")" 604 + 605 + resolver := &LocalImageResolver{} 606 + converter := NewMarkdownConverter().WithImageResolver(resolver, tmpDir) 607 + 608 + blocks, err := converter.ToLeaflet(markdown) 609 + shared.AssertNoError(t, err, "should convert "+tc.name) 610 + 611 + imgBlock := blocks[0].Block.(ImageBlock) 612 + shared.AssertEqual(t, tc.width, imgBlock.AspectRatio.Width, tc.name+" width") 613 + shared.AssertEqual(t, tc.height, imgBlock.AspectRatio.Height, tc.name+" height") 614 + } 280 615 }) 281 616 }) 282 617 }
+7 -6
internal/services/media_test.go
··· 7 7 "testing" 8 8 9 9 "github.com/stormlightlabs/noteleaf/internal/models" 10 + "github.com/stormlightlabs/noteleaf/internal/shared" 10 11 ) 11 12 12 13 func TestMediaServices(t *testing.T) { ··· 26 27 27 28 service := CreateMovieService() 28 29 _, err := service.Search(context.Background(), "error", 1, 10) 29 - AssertErrorContains(t, err, "search error") 30 + shared.AssertErrorContains(t, err, "search error", "") 30 31 }) 31 32 }) 32 33 ··· 55 56 56 57 service := CreateMovieService() 57 58 _, err := service.Get(context.Background(), "error") 58 - AssertErrorContains(t, err, "fetch error") 59 + shared.AssertErrorContains(t, err, "fetch error", "") 59 60 }) 60 61 }) 61 62 ··· 77 78 78 79 service := CreateMovieService() 79 80 err := service.Check(context.Background()) 80 - AssertErrorContains(t, err, "html fetch error") 81 + shared.AssertErrorContains(t, err, "html fetch error", "") 81 82 }) 82 83 }) 83 84 ··· 214 215 215 216 service := CreateTVService() 216 217 _, err := service.Search(context.Background(), "error", 1, 10) 217 - AssertErrorContains(t, err, "search error") 218 + shared.AssertErrorContains(t, err, "search error", "") 218 219 }) 219 220 }) 220 221 ··· 243 244 244 245 service := CreateTVService() 245 246 _, err := service.Get(context.Background(), "error") 246 - AssertErrorContains(t, err, "fetch error") 247 + shared.AssertErrorContains(t, err, "fetch error", "") 247 248 }) 248 249 }) 249 250 ··· 265 266 266 267 service := CreateTVService() 267 268 err := service.Check(context.Background()) 268 - AssertErrorContains(t, err, "html fetch error") 269 + shared.AssertErrorContains(t, err, "html fetch error", "") 269 270 }) 270 271 }) 271 272 })
+5 -5
internal/services/services_test.go
··· 117 117 t.Error("Search should return error for API failure") 118 118 } 119 119 120 - AssertErrorContains(t, err, "API returned status 500") 120 + shared.AssertErrorContains(t, err, "API returned status 500", "") 121 121 }) 122 122 123 123 t.Run("handles malformed JSON", func(t *testing.T) { ··· 135 135 t.Error("Search should return error for malformed JSON") 136 136 } 137 137 138 - AssertErrorContains(t, err, "failed to decode response") 138 + shared.AssertErrorContains(t, err, "failed to decode response", "") 139 139 }) 140 140 141 141 t.Run("handles context cancellation", func(t *testing.T) { ··· 231 231 t.Error("Get should return error for non-existent work") 232 232 } 233 233 234 - AssertErrorContains(t, err, "book not found") 234 + shared.AssertErrorContains(t, err, "book not found", "") 235 235 }) 236 236 237 237 t.Run("handles API error", func(t *testing.T) { ··· 248 248 t.Error("Get should return error for API failure") 249 249 } 250 250 251 - AssertErrorContains(t, err, "API returned status 500") 251 + shared.AssertErrorContains(t, err, "API returned status 500", "") 252 252 }) 253 253 }) 254 254 ··· 299 299 t.Error("Check should return error for API failure") 300 300 } 301 301 302 - AssertErrorContains(t, err, "open Library API returned status 503") 302 + shared.AssertErrorContains(t, err, "open Library API returned status 503", "") 303 303 }) 304 304 305 305 t.Run("handles network error", func(t *testing.T) {
-12
internal/services/test_utilities.go
··· 220 220 t.Errorf("expected to find TV show containing '%s' in results", expectedTitle) 221 221 } 222 222 223 - // 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 223 // CreateMovieService returns a new movie service for testing 236 224 func CreateMovieService() *MovieService { 237 225 return NewMovieService()