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 11 "github.com/stormlightlabs/noteleaf/internal/handlers" 12 "github.com/stormlightlabs/noteleaf/internal/services" 13 ) 14 15 func 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 }) 480 481 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 }) 563 564 t.Run("remove command with non-existent TV show ID", func(t *testing.T) {
··· 10 11 "github.com/stormlightlabs/noteleaf/internal/handlers" 12 "github.com/stormlightlabs/noteleaf/internal/services" 13 + "github.com/stormlightlabs/noteleaf/internal/shared" 14 ) 15 16 func 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 }) 481 482 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 }) 564 565 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 } 299 300 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 } 375 376 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 } 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 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 } 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 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 5 package public 6 7 import ( 8 "bytes" 9 "fmt" 10 "strings" 11 12 "github.com/gomarkdown/markdown/ast" ··· 21 FromLeaflet(blocks []BlockWrap) (string, error) 22 } 23 24 // MarkdownConverter implements the [Converter] interface 25 type MarkdownConverter struct { 26 - extensions parser.Extensions 27 } 28 29 type formatContext struct { ··· 37 return &MarkdownConverter{ 38 extensions: extensions, 39 } 40 } 41 42 // ToLeaflet converts markdown to leaflet blocks 43 func (c *MarkdownConverter) ToLeaflet(markdown string) ([]BlockWrap, error) { 44 p := parser.NewWithExtensions(c.extensions) 45 doc := p.Parse([]byte(markdown)) 46 47 var blocks []BlockWrap 48 49 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 }) 78 } 79 } 80 81 return blocks, nil 82 } 83 84 // 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 } 97 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) 101 if strings.TrimSpace(text) == "" { 102 return nil 103 } 104 105 - return &BlockWrap{ 106 Type: TypeBlock, 107 Block: TextBlock{ 108 Type: TypeTextBlock, 109 Plaintext: text, 110 Facets: facets, 111 }, 112 - } 113 } 114 115 // convertCodeBlock converts an AST code block to a leaflet CodeBlock ··· 126 } 127 128 // 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 } 140 141 // convertList converts an AST list to a leaflet UnorderedListBlock 142 - func (c *MarkdownConverter) convertList(node *ast.List) *BlockWrap { 143 var items []ListItem 144 145 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 } 162 163 // 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 } 175 176 - // extractTextAndFacets extracts plaintext and facets from an AST node 177 - func (c *MarkdownConverter) extractTextAndFacets(node ast.Node) (string, []Facet) { 178 var buf bytes.Buffer 179 var facets []Facet 180 offset := 0 181 182 var stack []formatContext ··· 189 buf.WriteString(content) 190 191 if len(stack) > 0 { 192 - ctx := stack[len(stack)-1] 193 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 } 272 case *ast.Softbreak, *ast.Hardbreak: 273 if entering { 274 buf.WriteString(" ") ··· 277 } 278 return ast.GoToNext 279 }) 280 - return buf.String(), facets 281 } 282 283 // 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 7 package public 8 9 import ( 10 "bytes" 11 "fmt" 12 + "image" 13 + _ "image/gif" 14 + _ "image/jpeg" 15 + _ "image/png" 16 + "os" 17 + "path/filepath" 18 "strings" 19 20 "github.com/gomarkdown/markdown/ast" ··· 29 FromLeaflet(blocks []BlockWrap) (string, error) 30 } 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 + 92 // MarkdownConverter implements the [Converter] interface 93 type MarkdownConverter struct { 94 + extensions parser.Extensions 95 + imageResolver ImageResolver 96 + basePath string // Base path for resolving relative image paths 97 } 98 99 type 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 } 118 119 // ToLeaflet converts markdown to leaflet blocks 120 func (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 + } 140 141 var blocks []BlockWrap 142 143 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...) 152 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 } 177 178 return blocks, nil 179 } 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 + 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 } 213 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 + 222 if strings.TrimSpace(text) == "" { 223 return nil 224 } 225 226 + return []BlockWrap{{ 227 Type: TypeBlock, 228 Block: TextBlock{ 229 Type: TypeTextBlock, 230 Plaintext: text, 231 Facets: facets, 232 }, 233 + }} 234 } 235 236 // convertCodeBlock converts an AST code block to a leaflet CodeBlock ··· 247 } 248 249 // 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 } 261 262 // 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 265 266 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 } 283 284 // 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 } 296 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) { 348 var buf bytes.Buffer 349 var facets []Facet 350 + var blocks []BlockWrap 351 offset := 0 352 353 var stack []formatContext ··· 360 buf.WriteString(content) 361 362 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 } 489 490 // FromLeaflet converts leaflet blocks back to markdown
+335
internal/public/convert_test.go
··· 1 package public 2 3 import ( 4 "strings" 5 "testing" 6 ··· 198 199 shared.AssertTrue(t, len(text.Facets) >= 3, "should have at least 3 facets") 200 }) 201 }) 202 203 t.Run("Round-trip Conversion", func(t *testing.T) { ··· 277 blocks, err := converter.ToLeaflet(markdown) 278 shared.AssertNoError(t, err, "should succeed") 279 shared.AssertEqual(t, 2, len(blocks), "should have 2 blocks") 280 }) 281 }) 282 }
··· 1 package public 2 3 import ( 4 + "image" 5 + "image/png" 6 + "os" 7 + "path/filepath" 8 "strings" 9 "testing" 10 ··· 202 203 shared.AssertTrue(t, len(text.Facets) >= 3, "should have at least 3 facets") 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 + }) 323 }) 324 325 t.Run("Round-trip Conversion", func(t *testing.T) { ··· 399 blocks, err := converter.ToLeaflet(markdown) 400 shared.AssertNoError(t, err, "should succeed") 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 + } 615 }) 616 }) 617 }
+7 -6
internal/services/media_test.go
··· 7 "testing" 8 9 "github.com/stormlightlabs/noteleaf/internal/models" 10 ) 11 12 func TestMediaServices(t *testing.T) { ··· 26 27 service := CreateMovieService() 28 _, err := service.Search(context.Background(), "error", 1, 10) 29 - AssertErrorContains(t, err, "search error") 30 }) 31 }) 32 ··· 55 56 service := CreateMovieService() 57 _, err := service.Get(context.Background(), "error") 58 - AssertErrorContains(t, err, "fetch error") 59 }) 60 }) 61 ··· 77 78 service := CreateMovieService() 79 err := service.Check(context.Background()) 80 - AssertErrorContains(t, err, "html fetch error") 81 }) 82 }) 83 ··· 214 215 service := CreateTVService() 216 _, err := service.Search(context.Background(), "error", 1, 10) 217 - AssertErrorContains(t, err, "search error") 218 }) 219 }) 220 ··· 243 244 service := CreateTVService() 245 _, err := service.Get(context.Background(), "error") 246 - AssertErrorContains(t, err, "fetch error") 247 }) 248 }) 249 ··· 265 266 service := CreateTVService() 267 err := service.Check(context.Background()) 268 - AssertErrorContains(t, err, "html fetch error") 269 }) 270 }) 271 })
··· 7 "testing" 8 9 "github.com/stormlightlabs/noteleaf/internal/models" 10 + "github.com/stormlightlabs/noteleaf/internal/shared" 11 ) 12 13 func TestMediaServices(t *testing.T) { ··· 27 28 service := CreateMovieService() 29 _, err := service.Search(context.Background(), "error", 1, 10) 30 + shared.AssertErrorContains(t, err, "search error", "") 31 }) 32 }) 33 ··· 56 57 service := CreateMovieService() 58 _, err := service.Get(context.Background(), "error") 59 + shared.AssertErrorContains(t, err, "fetch error", "") 60 }) 61 }) 62 ··· 78 79 service := CreateMovieService() 80 err := service.Check(context.Background()) 81 + shared.AssertErrorContains(t, err, "html fetch error", "") 82 }) 83 }) 84 ··· 215 216 service := CreateTVService() 217 _, err := service.Search(context.Background(), "error", 1, 10) 218 + shared.AssertErrorContains(t, err, "search error", "") 219 }) 220 }) 221 ··· 244 245 service := CreateTVService() 246 _, err := service.Get(context.Background(), "error") 247 + shared.AssertErrorContains(t, err, "fetch error", "") 248 }) 249 }) 250 ··· 266 267 service := CreateTVService() 268 err := service.Check(context.Background()) 269 + shared.AssertErrorContains(t, err, "html fetch error", "") 270 }) 271 }) 272 })
+5 -5
internal/services/services_test.go
··· 117 t.Error("Search should return error for API failure") 118 } 119 120 - AssertErrorContains(t, err, "API returned status 500") 121 }) 122 123 t.Run("handles malformed JSON", func(t *testing.T) { ··· 135 t.Error("Search should return error for malformed JSON") 136 } 137 138 - AssertErrorContains(t, err, "failed to decode response") 139 }) 140 141 t.Run("handles context cancellation", func(t *testing.T) { ··· 231 t.Error("Get should return error for non-existent work") 232 } 233 234 - AssertErrorContains(t, err, "book not found") 235 }) 236 237 t.Run("handles API error", func(t *testing.T) { ··· 248 t.Error("Get should return error for API failure") 249 } 250 251 - AssertErrorContains(t, err, "API returned status 500") 252 }) 253 }) 254 ··· 299 t.Error("Check should return error for API failure") 300 } 301 302 - AssertErrorContains(t, err, "open Library API returned status 503") 303 }) 304 305 t.Run("handles network error", func(t *testing.T) {
··· 117 t.Error("Search should return error for API failure") 118 } 119 120 + shared.AssertErrorContains(t, err, "API returned status 500", "") 121 }) 122 123 t.Run("handles malformed JSON", func(t *testing.T) { ··· 135 t.Error("Search should return error for malformed JSON") 136 } 137 138 + shared.AssertErrorContains(t, err, "failed to decode response", "") 139 }) 140 141 t.Run("handles context cancellation", func(t *testing.T) { ··· 231 t.Error("Get should return error for non-existent work") 232 } 233 234 + shared.AssertErrorContains(t, err, "book not found", "") 235 }) 236 237 t.Run("handles API error", func(t *testing.T) { ··· 248 t.Error("Get should return error for API failure") 249 } 250 251 + shared.AssertErrorContains(t, err, "API returned status 500", "") 252 }) 253 }) 254 ··· 299 t.Error("Check should return error for API failure") 300 } 301 302 + shared.AssertErrorContains(t, err, "open Library API returned status 503", "") 303 }) 304 305 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 } 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 // CreateMovieService returns a new movie service for testing 236 func CreateMovieService() *MovieService { 237 return NewMovieService()
··· 220 t.Errorf("expected to find TV show containing '%s' in results", expectedTitle) 221 } 222 223 // CreateMovieService returns a new movie service for testing 224 func CreateMovieService() *MovieService { 225 return NewMovieService()