···11+package converter
22+33+import (
44+ "encoding/json"
55+ "fmt"
66+ "strings"
77+88+ "github.com/marius/leaflet-hugo-sync/internal/atproto"
99+)
1010+1111+type Converter struct {
1212+ // We might need to track image downloads here or just return image references?
1313+ // For now, let's just return the Markdown text and a list of image blobs to download.
1414+}
1515+1616+type ConversionResult struct {
1717+ Markdown string
1818+ Images []ImageRef
1919+}
2020+2121+type ImageRef struct {
2222+ Blob atproto.Blob
2323+ Alt string
2424+}
2525+2626+func NewConverter() *Converter {
2727+ return &Converter{}
2828+}
2929+3030+func (c *Converter) ConvertLeaflet(doc *atproto.LeafletDocument) (*ConversionResult, error) {
3131+ var sb strings.Builder
3232+ var images []ImageRef
3333+3434+ for _, page := range doc.Pages {
3535+ for _, blockWrapper := range page.Blocks {
3636+ // Unmarshal base block to check type
3737+ var base atproto.BaseBlock
3838+ if err := json.Unmarshal(blockWrapper.Block, &base); err != nil {
3939+ continue
4040+ }
4141+4242+ switch base.Type {
4343+ case "pub.leaflet.blocks.text":
4444+ var textBlock atproto.TextBlock
4545+ if err := json.Unmarshal(blockWrapper.Block, &textBlock); err != nil {
4646+ continue
4747+ }
4848+ sb.WriteString(c.renderText(&textBlock) + "\n\n")
4949+5050+ case "pub.leaflet.blocks.code":
5151+ var codeBlock atproto.CodeBlock
5252+ if err := json.Unmarshal(blockWrapper.Block, &codeBlock); err != nil {
5353+ continue
5454+ }
5555+ // Ensure language is not nil or empty
5656+ lang := codeBlock.Language
5757+ if lang == "" {
5858+ lang = "text"
5959+ }
6060+ sb.WriteString(fmt.Sprintf("\n```%s\n%s\n```\n\n", lang, codeBlock.Plaintext))
6161+6262+ case "pub.leaflet.blocks.unorderedList":
6363+ var listBlock atproto.UnorderedListBlock
6464+ if err := json.Unmarshal(blockWrapper.Block, &listBlock); err != nil {
6565+ continue
6666+ }
6767+ c.renderList(&sb, listBlock.Children, 0)
6868+ sb.WriteString("\n")
6969+7070+ case "pub.leaflet.blocks.image":
7171+ var imgBlock atproto.ImageBlock
7272+ if err := json.Unmarshal(blockWrapper.Block, &imgBlock); err != nil {
7373+ continue
7474+ }
7575+ // We use a placeholder path that main.go will resolve
7676+ // Actually, main.go needs to know about this.
7777+ // Let's assume standard markdown image syntax: 
7878+ // The downloader will replace it or we pre-calculate the path?
7979+ // Better: Return the blob CID as the URL, and main.go does a string replace or we handle it here if we pass config.
8080+ // For now, let's use the blob ref link as the URL.
8181+ sb.WriteString(fmt.Sprintf("\n\n", imgBlock.Alt, imgBlock.Image.Ref.Link))
8282+ images = append(images, ImageRef{Blob: imgBlock.Image, Alt: imgBlock.Alt})
8383+8484+ case "pub.leaflet.blocks.bskyPost":
8585+ var postBlock atproto.BskyPostBlock
8686+ if err := json.Unmarshal(blockWrapper.Block, &postBlock); err != nil {
8787+ continue
8888+ }
8989+ // Render a link to the post for now, as we can't easily embed it without JS
9090+ postURL := fmt.Sprintf("https://bsky.app/profile/%s/post/%s", "did:...", lastPathPart(postBlock.PostRef.Uri))
9191+ // We don't have the handle here easily to make a pretty URL, but we can try.
9292+ // Actually, let's just make a blockquote link.
9393+ sb.WriteString(fmt.Sprintf("> [View on Bluesky](%s)\n\n", postURL)) // TODO: Improve this
9494+ }
9595+ }
9696+ }
9797+9898+ return &ConversionResult{
9999+ Markdown: sb.String(),
100100+ Images: images,
101101+ }, nil
102102+}
103103+104104+func (c *Converter) renderText(block *atproto.TextBlock) string {
105105+ // Apply facets
106106+ // Facets are ranges. We need to insert markdown syntax at specific indices.
107107+ // This is tricky because inserting characters shifts indices.
108108+ // Best approach: Slice the string and reconstruct it.
109109+110110+ // Sort facets by start index (descending) to avoid shifting issues?
111111+ // Actually, we should iterate from start to end, keeping track of current index.
112112+113113+ // Simplify: Just handle links for now.
114114+ // NOTE: Facets in ATProto are byte-offsets, not rune-offsets. Go strings are UTF-8.
115115+116116+ // Convert string to byte slice for easier indexing
117117+ data := []byte(block.Plaintext)
118118+119119+ // Map of byte_index -> string_to_insert
120120+ // But we wrap text.
121121+122122+ // Let's just do a linear pass if facets are non-overlapping and sorted.
123123+ // They should be.
124124+125125+ var sb strings.Builder
126126+ lastPos := 0
127127+128128+ for _, facet := range block.Facets {
129129+ start := facet.Index.ByteStart
130130+ end := facet.Index.ByteEnd
131131+132132+ if start < lastPos {
133133+ continue // Overlap or out of order
134134+ }
135135+136136+ // Append text before facet
137137+ sb.Write(data[lastPos:start])
138138+139139+ // Handle feature
140140+ text := string(data[start:end])
141141+ replacement := text
142142+143143+ for _, feat := range facet.Features {
144144+ if feat.Type == "pub.leaflet.richtext.facet#link" {
145145+ replacement = fmt.Sprintf("[%s](%s)", text, feat.URI)
146146+ } else if feat.Type == "pub.leaflet.richtext.facet#didMention" {
147147+ replacement = fmt.Sprintf("[%s](https://bsky.app/profile/%s)", text, feat.Did)
148148+ }
149149+ // code facet? pub.leaflet.richtext.facet#code -> `text`
150150+ if feat.Type == "pub.leaflet.richtext.facet#code" {
151151+ replacement = fmt.Sprintf("`%s`", text)
152152+ }
153153+ }
154154+155155+ sb.WriteString(replacement)
156156+ lastPos = end
157157+ }
158158+159159+ sb.Write(data[lastPos:])
160160+161161+ return sb.String()
162162+}
163163+164164+func (c *Converter) renderList(sb *strings.Builder, items []atproto.ListItem, depth int) {
165165+ indent := strings.Repeat(" ", depth)
166166+ for _, item := range items {
167167+ // Unmarshal content (TextBlock)
168168+ var textBlock atproto.TextBlock
169169+ if err := json.Unmarshal(item.Content, &textBlock); err == nil {
170170+ sb.WriteString(fmt.Sprintf("%s- %s\n", indent, c.renderText(&textBlock)))
171171+ }
172172+ if len(item.Children) > 0 {
173173+ c.renderList(sb, item.Children, depth+1)
174174+ }
175175+ }
176176+}
177177+178178+func lastPathPart(uri string) string {
179179+ parts := strings.Split(uri, "/")
180180+ return parts[len(parts)-1]
181181+}