diff --git a/cmd/cmdg/cmdg.go b/cmd/cmdg/cmdg.go index 894a24c..0b45b1a 100644 --- a/cmd/cmdg/cmdg.go +++ b/cmd/cmdg/cmdg.go @@ -60,11 +60,10 @@ var ( verbose = flag.Bool("verbose", false, "Turn on verbose logging.") shell = flag.String("shell", "/bin/sh", "Shell to shell out to.") versionFlag = flag.Bool("version", false, "Show version and exit.") - lynx = flag.String("lynx", "lynx", "HTML render binary.") enableSign = flag.Bool("sign", false, "Send signed emails by default.") + imageProtocol = flag.String("image_protocol", "none", "Terminal image protocol (none, kitty, iterm2, auto).") updateSender = flag.String("update_sender", "", `Update default sender address. E.g.: "John Doe" `) - conn *cmdg.CmdG // Relative to configDir. @@ -133,6 +132,9 @@ func run(ctx context.Context) error { log.Errorf("Bailing due to error: %v", err) } log.Infof("MessageView returned, stopping keys") + if *imageProtocol != "none" { + cmdg.ClearImages(*imageProtocol) + } keys.Stop() log.Infof("Shutting down") return nil @@ -146,8 +148,7 @@ func main() { syscall.Umask(0077) flag.Parse() cmdg.Version = version - - cmdg.Lynx = *lynx + cmdg.PreferredImageProtocol = *imageProtocol log.Infof("cmdg %s", version) diff --git a/cmd/cmdg/compose_test.go b/cmd/cmdg/compose_test.go index a87dbcf..78bcc3b 100644 --- a/cmd/cmdg/compose_test.go +++ b/cmd/cmdg/compose_test.go @@ -208,6 +208,36 @@ Content-Disposition: inline Content-Type: text/plain; charset="UTF-8" World +--[a-z0-9]+--`)), + }, + { + name: "With attachments", + msg: "To: foo@bar.com\nSubject: hello\n\nWorld", + attachments: []*file{ + {name: "test1.txt", content: []byte("content1")}, + {name: "test2.txt", content: []byte("content2")}, + }, + matching: regexp.MustCompile(crnl(`MIME-Version: 1.0 +Subject: hello +To: foo@bar.com +Content-Type: multipart/mixed; boundary="[a-z0-9]+" +Content-Disposition: inline + +--[a-z0-9]+ +Content-Disposition: inline +Content-Type: text/plain; charset="UTF-8" + +World +--[a-z0-9]+ +Content-Disposition: attachment; filename="test1.txt" +Content-Type: application/octet-stream; name="test1.txt" + +content1 +--[a-z0-9]+ +Content-Disposition: attachment; filename="test2.txt" +Content-Type: application/octet-stream; name="test2.txt" + +content2 --[a-z0-9]+--`)), }, } diff --git a/cmd/cmdg/view_openmessage.go b/cmd/cmdg/view_openmessage.go index 2663e75..50a0b93 100644 --- a/cmd/cmdg/view_openmessage.go +++ b/cmd/cmdg/view_openmessage.go @@ -109,13 +109,17 @@ func help(txt string, keys *input.Input) error { } } +type bodyUpdate struct { + lines []string +} + // OpenMessageView is the view for an open message. type OpenMessageView struct { msg *cmdg.Message keys *input.Input screen *display.Screen - update chan struct{} + update chan bodyUpdate errors chan error inIncrementalSearch bool @@ -142,16 +146,51 @@ func NewOpenMessageView(ctx context.Context, msg *cmdg.Message, in *input.Input) msg: msg, keys: in, screen: screen, - update: make(chan struct{}), + update: make(chan bodyUpdate, 5), errors: make(chan error, 20), } + if *imageProtocol != "none" { + ov.preferHTML = true + } + + triggerUpdate := func() { + go func() { + gb := ov.msg.GetBody + if ov.preferHTML { + gb = ov.msg.GetBodyHTML + } + b, err := gb(ctx) + if err != nil { + ov.errors <- errors.Wrapf(err, "Getting message body") + return + } + if *imageProtocol != "none" { + b = ov.msg.ProcessInlineImages(ctx, b, ov.screen.Width, ov.screen.Height) + + // Ensure images are uploaded even if already cached in memory. + proto := *imageProtocol + if proto == "auto" { + proto = cmdg.DetectImageProtocol() + } + if proto == "kitty" { + for _, img := range ov.msg.InlineImages() { + if img.Found && len(img.PNGContents) > 0 { + ov.msg.KittyUploadImage(img.PNGContents, img.KittyID) + } + } + } + } + ov.update <- bodyUpdate{lines: display.Wrap(b, ov.screen.Width)} + }() + } + go func() { st := time.Now() if err := msg.Preload(ctx, cmdg.LevelFull); err != nil { ov.errors <- err } log.Infof("Got full message in %v", time.Since(st)) - ov.update <- struct{}{} + triggerUpdate() }() return ov, err } @@ -281,6 +320,7 @@ func (ov *OpenMessageView) Draw(lines []string, scroll int) error { line++ // Draw body. + bodyStart := line if len(lines) > scroll { for _, l := range lines[scroll:] { l = strings.TrimRight(l, "\r ") @@ -294,6 +334,77 @@ func (ov *OpenMessageView) Draw(lines []string, scroll int) error { log.Errorf("Scroll too high! %d >= %d", scroll, len(lines)) } ov.screen.Printlnf(ov.screen.Height-2, "%s", strings.Repeat("—", ov.screen.Width)) + + // Draw images. + if *imageProtocol != "none" { + proto := *imageProtocol + if proto == "auto" { + proto = cmdg.DetectImageProtocol() + } + if proto != "" && proto != "none" { + // Clear previous placements to avoid "smearing" during scroll. + // This uses d=a to keep image data in memory. + ov.screen.PostDraw += "\x1b_Ga=d,d=a\x1b\\" + + bodyEnd := ov.screen.Height - 2 + + for _, img := range ov.msg.InlineImages() { + // Only draw if the marker was found in this body version. + if !img.Found { + continue + } + if img.InViewport(scroll, bodyStart, ov.screen.Height) && len(img.Contents) > 0 { + screenY := img.Y - scroll + bodyStart + drawY := screenY + drawX := img.X + drawH := img.Height + + pxX := 0 + pxY := 0 + pxW := img.PixelWidth + pxH := img.PixelHeight + + // Calculate pixel-per-cell ratio. + ratioY := float64(img.PixelHeight) / float64(img.Height) + + // Clip at the top. + if drawY < bodyStart { + vOffset := bodyStart - drawY + pxY = int(float64(vOffset) * ratioY) + pxH -= pxY + drawH -= vOffset + drawY = bodyStart + } + + // Clip at the bottom. + if drawY+drawH > bodyEnd { + clippedH := (drawY + drawH) - bodyEnd + pxH -= int(float64(clippedH) * ratioY) + drawH -= clippedH + } + + if drawH <= 0 { + continue + } + + log.Infof("Atomic image placement at %d,%d (c=%d r=%d, pxY=%d, pxH=%d)", drawX, drawY, img.Width, drawH, pxY, pxH) + var seq string + switch proto { + case "kitty": + seq = ov.msg.KittyDisplayImage(img.KittyID, img.Width, drawH, pxX, pxY, pxW, pxH) + case "iterm2": + seq = ov.msg.ITerm2Encode(img.PNGContents, img.Width, drawH) + } + if seq != "" { + ov.screen.PostDraw += fmt.Sprintf("\033[%d;%dH%s", drawY+1, drawX+1, seq) + } + } + } + ov.screen.PostDraw += fmt.Sprintf("\033[%d;1H", ov.screen.Height-1) + } + } + + ov.screen.Draw() return nil } @@ -420,6 +531,15 @@ func (ov *OpenMessageView) incrementalSearch(ctx context.Context, inlines []stri // Run runs the open message view event loop. func (ov *OpenMessageView) Run(ctx context.Context) (*MessageViewOp, error) { log.Infof("Running OpenMessageView") + if *imageProtocol != "none" { + proto := *imageProtocol + if proto == "auto" { + proto = cmdg.DetectImageProtocol() + } + // Clear all image data from PREVIOUS emails. + cmdg.ClearImageData(proto) + defer cmdg.ClearImageData(proto) + } scroll := 0 initScreen := func() error { var err error @@ -435,6 +555,39 @@ func (ov *OpenMessageView) Run(ctx context.Context) (*MessageViewOp, error) { } ov.screen.Printf(0, 0, "Loading…") ov.screen.Draw() + + triggerUpdate := func() { + go func() { + gb := ov.msg.GetBody + if ov.preferHTML { + gb = ov.msg.GetBodyHTML + } + b, err := gb(ctx) + if err != nil { + ov.errors <- errors.Wrapf(err, "Getting message body") + return + } + if *imageProtocol != "none" { + log.Infof("Pre-processing inline images for body length %d", len(b)) + b = ov.msg.ProcessInlineImages(ctx, b, ov.screen.Width, ov.screen.Height) + + // Ensure images are uploaded even if already cached in memory. + proto := *imageProtocol + if proto == "auto" { + proto = cmdg.DetectImageProtocol() + } + if proto == "kitty" { + for _, img := range ov.msg.InlineImages() { + if img.Found && len(img.PNGContents) > 0 { + ov.msg.KittyUploadImage(img.PNGContents, img.KittyID) + } + } + } + } + ov.update <- bodyUpdate{lines: display.Wrap(b, ov.screen.Width)} + }() + } + var lines []string for { select { @@ -446,44 +599,16 @@ func (ov *OpenMessageView) Run(ctx context.Context) (*MessageViewOp, error) { return nil, err } scroll = s - go func() { - ov.update <- struct{}{} - }() + triggerUpdate() case err := <-ov.errors: if err != nil { showError(ov.screen, ov.keys, err.Error()) ov.screen.Draw() } continue - case <-ov.update: - log.Infof("Message arrived") - gb := ov.msg.GetBody - if ov.preferHTML { - gb = ov.msg.GetBodyHTML - } - b, err := gb(ctx) - if err != nil { - ov.errors <- errors.Wrapf(err, "Getting message body") - } else { - lines = []string{} - for _, l := range strings.Split(b, "\n") { - if len(l) == 0 { - lines = append(lines, "") - continue - } - for len(l) > 0 { - // TODO: break on runewidth - // TODO: break on word boundary - if len(l) > ov.screen.Width { - lines = append(lines, l[:ov.screen.Width]) - l = l[ov.screen.Width:] - } else { - lines = append(lines, l) - l = "" - } - } - } - } + case up := <-ov.update: + log.Infof("Body update arrived") + lines = up.lines go func() { if ov.msg.IsUnread() { st := time.Now() @@ -493,11 +618,7 @@ func (ov *OpenMessageView) Run(ctx context.Context) (*MessageViewOp, error) { log.Infof("Marked unread in %v", time.Since(st)) } } - // Does not need to be signaled to - // messageview; label list gets - // updated by RemoveLabelID. }() - // Redraw could include fewer lines, because 'H' toggled HTML. ov.screen.Clear() // TODO: double check that scroll is not too high after `lines` was recreated. @@ -516,7 +637,7 @@ func (ov *OpenMessageView) Run(ctx context.Context) (*MessageViewOp, error) { if err := ov.msg.Reload(ctx, cmdg.LevelFull); err != nil { ov.errors <- errors.Wrap(err, "reloading message") } - ov.update <- struct{}{} + triggerUpdate() }() case "?", input.F1: if err := help(openMessageViewHelp, ov.keys); err != nil { @@ -652,9 +773,7 @@ func (ov *OpenMessageView) Run(ctx context.Context) (*MessageViewOp, error) { case "H": ov.preferHTML = !ov.preferHTML scroll = 0 - go func() { - ov.update <- struct{}{} - }() + triggerUpdate() case "e": // Archive if err := ov.msg.RemoveLabelID(ctx, cmdg.Inbox); err != nil { ov.errors <- fmt.Errorf("Failed to archive : %v", err) diff --git a/go.mod b/go.mod index 96e15a6..d8235c7 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,15 @@ module github.com/ThomasHabets/cmdg -go 1.24.0 +go 1.25.0 require ( - github.com/mattn/go-runewidth v0.0.16 + github.com/mattn/go-runewidth v0.0.17 github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.3 - golang.org/x/crypto v0.46.0 - golang.org/x/net v0.48.0 + golang.org/x/crypto v0.49.0 + golang.org/x/net v0.52.0 golang.org/x/oauth2 v0.34.0 - golang.org/x/sys v0.39.0 + golang.org/x/sys v0.42.0 google.golang.org/api v0.214.0 ) @@ -17,7 +17,21 @@ require ( cloud.google.com/go/auth v0.13.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/JohannesKaufmann/html-to-markdown v1.6.0 // indirect + github.com/PuerkitoBio/goquery v1.12.0 // indirect + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/glamour v1.0.0 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.10.2 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -25,14 +39,25 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sixel v0.0.9 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/soniakeys/quant v1.0.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect - golang.org/x/term v0.38.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/go.sum b/go.sum index 96e3ffd..cae0674 100644 --- a/go.sum +++ b/go.sum @@ -4,11 +4,41 @@ cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyT cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k= +github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ= +github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= +github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= +github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -18,6 +48,7 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= @@ -28,21 +59,58 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gT github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= +github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sixel v0.0.9 h1:ncx/rVU35Ut7/6gpVk4deC4/Wp2js9fDKmFmWnzmGoY= +github.com/mattn/go-sixel v0.0.9/go.mod h1:mfichvavqIDFW14LGU24ux/UZ/wF0/hG+4pUWOWrQgM= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y= +github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= @@ -57,21 +125,102 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.214.0 h1:h2Gkq07OYi6kusGOaT/9rnNljuXmqPnaig7WGPmKbwA= @@ -85,6 +234,9 @@ google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhH google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/cmdg/message.go b/pkg/cmdg/message.go index 7d391bc..e818023 100644 --- a/pkg/cmdg/message.go +++ b/pkg/cmdg/message.go @@ -4,20 +4,28 @@ import ( "bytes" "context" "encoding/base64" + "encoding/json" "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + "image/png" "io" "io/ioutil" "mime" "mime/multipart" "mime/quotedprintable" + "net/http" "net/mail" + "os" "os/exec" "regexp" + "runtime/debug" "strings" "sync" + "sync/atomic" "time" - "github.com/pkg/errors" log "github.com/sirupsen/logrus" "golang.org/x/net/html/charset" @@ -25,7 +33,13 @@ import ( "github.com/ThomasHabets/cmdg/pkg/display" "github.com/ThomasHabets/cmdg/pkg/gpg" -) + ) + + var ( + // cmdgRenderBinary is the name of the external renderer binary. + cmdgRenderBinary = "cmdg-image-render" + ) + // Special labels. const ( @@ -44,8 +58,8 @@ var ( // GPG is the handle to a GPG config. GPG *gpg.GPG - // Lynx is the executable to use as web browser to use to render HTML to text. - Lynx = "lynx" + // PreferredImageProtocol is set by the main package to communicate the user's preference. + PreferredImageProtocol string // Openssl is the executable is used to verify some signatures. Openssl = "openssl" @@ -101,6 +115,306 @@ func (a *Attachment) Download(ctx context.Context) ([]byte, error) { return []byte(d), nil } +type InlineImage struct { + Source string // CID or URL + X, Y int // Position in the text buffer + Width int // Width in terminal cells + Height int // Height in terminal cells + PixelWidth int // Original width in pixels + PixelHeight int // Original height in pixels + Contents []byte // Cached raw bytes + PNGContents []byte // Converted PNG bytes + KittyID uint32 // ID for Kitty protocol + Found bool // True if marker was found in the current body +} + +func ClearImages(proto string) { + if proto == "auto" { + proto = DetectImageProtocol() + } + log.Debugf("Clearing images for protocol %q", proto) + switch proto { + case "kitty": + // a=d: action=delete + // d=a: delete all placements on screen, but keep image data in memory + _, _ = os.Stdout.WriteString("\x1b_Ga=d,d=a\x1b\\") + _ = os.Stdout.Sync() + case "iterm2": + // iTerm2 clears on screen clear. + } +} + +// ClearImageData clears both placements and image data from the terminal. +func ClearImageData(proto string) { + if proto == "auto" { + proto = DetectImageProtocol() + } + log.Infof("Clearing image data for protocol %q", proto) + if proto == "kitty" { + // d=A: delete all images and placements + _, _ = os.Stdout.WriteString("\x1b_Ga=d,d=A\x1b\\") + _ = os.Stdout.Sync() + } +} + +func (img *InlineImage) InViewport(scroll, headerLines, screenHeight int) bool { + + screenY := img.Y - scroll + headerLines + // Image must be at least partially visible. + if screenY+img.Height <= headerLines { + return false + } + if screenY >= screenHeight-2 { + return false + } + return true +} + +func findPartByCID(part *gmail.MessagePart, cid string) *gmail.MessagePart { + for _, h := range part.Headers { + if strings.ToLower(h.Name) == "content-id" && (h.Value == cid || h.Value == "<"+cid+">") { + return part + } + } + for _, p := range part.Parts { + if found := findPartByCID(p, cid); found != nil { + return found + } + } + return nil +} + +func calculateCellOccupancy(pixelW, pixelH, termW, termH int) (int, int) { + // Assume 10x20 pixels per cell for now. + // TODO: dynamically detect this if possible. + cellW := pixelW / 10 + cellH := pixelH / 20 + if cellW == 0 { + cellW = 1 + } + if cellH == 0 { + cellH = 1 + } + + // Scale to fit viewport if necessary. + if cellW > termW { + ratio := float64(termW) / float64(cellW) + cellW = termW + cellH = int(float64(cellH) * ratio) + } + if cellH > termH { + ratio := float64(termH) / float64(cellH) + cellH = termH + cellW = int(float64(cellW) * ratio) + } + if cellW == 0 { + cellW = 1 + } + if cellH == 0 { + cellH = 1 + } + return cellW, cellH +} + +func KittyEncode(data []byte, w, h int) string { + b64 := base64.StdEncoding.EncodeToString(data) + // We use the simplest Kitty 'direct' protocol: a=T (transfer and display), t=d (direct), format is auto-detected. + // We wrap it in a string that can be printed. + return fmt.Sprintf("\x1b_Ga=T,t=d,c=%d,r=%d;%s\x1b\\", w, h, b64) +} + +func ITerm2Encode(data []byte, w, h int) string { + b64 := base64.StdEncoding.EncodeToString(data) + return fmt.Sprintf("\x1b]1337;File=width=%d;height=%d;inline=1:%s\x1b\\", w, h, b64) +} +func DetectImageProtocol() string { + if PreferredImageProtocol == "none" { + return "none" + } + if PreferredImageProtocol != "auto" && PreferredImageProtocol != "" { + return PreferredImageProtocol + } + + term := strings.ToLower(os.Getenv("TERM")) + termProg := strings.ToLower(os.Getenv("TERM_PROGRAM")) + + // Kitty / Ghostty / WezTerm (Kitty protocol) + if strings.Contains(term, "kitty") || os.Getenv("KITTY_WINDOW_ID") != "" { + return "kitty" + } + if strings.Contains(term, "ghostty") || termProg == "ghostty" || os.Getenv("GHOSTTY_RESOURCES_DIR") != "" { + return "kitty" + } + if strings.Contains(term, "wezterm") || termProg == "wezterm" || os.Getenv("WEZTERM_EXECUTABLE") != "" { + return "kitty" + } + + // iTerm2 + if termProg == "iterm.app" || termProg == "iterm2" || os.Getenv("ITERM_SESSION_ID") != "" { + return "iterm2" + } + + // Fallback for modern terminals + if os.Getenv("COLORTERM") != "" || strings.Contains(term, "256color") { + return "kitty" + } + + return "" +} + +// ResolveCID resolves a CID or URL to raw bytes. +func (msg *Message) ResolveCID(ctx context.Context, cid string) ([]byte, error) { + if strings.HasPrefix(cid, "http://") || strings.HasPrefix(cid, "https://") { + req, err := http.NewRequestWithContext(ctx, "GET", cid, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch image: %s", resp.Status) + } + return ioutil.ReadAll(resp.Body) + } + + cid = strings.TrimPrefix(cid, "cid:") + msg.m.RLock() + payload := msg.Response.Payload + msg.m.RUnlock() + part := findPartByCID(payload, cid) + if part == nil { + return nil, fmt.Errorf("CID %q not found", cid) + } + if part.Body.Data != "" { + d, err := MIMEDecode(part.Body.Data) + return []byte(d), err + } + if part.Body.AttachmentId != "" { + att := &Attachment{ + MsgID: msg.ID, + ID: part.Body.AttachmentId, + Part: part, + conn: msg.conn, + } + return att.Download(ctx) + } + return nil, fmt.Errorf("CID %q has no data", cid) +} + +// ProcessInlineImages identifies markers and calculates their terminal coordinates. +func (msg *Message) ProcessInlineImages(ctx context.Context, body string, termW, termH int) string { + proto := DetectImageProtocol() + if proto == "none" { + return body + } + + msg.m.RLock() + images := make([]*InlineImage, len(msg.inlineImages)) + copy(images, msg.inlineImages) + msg.m.RUnlock() + + log.Infof("Processing %d inline images against body length %d", len(images), len(body)) + + // We process line by line to accurately track the terminal Y coordinate. + lines := display.Wrap(body, termW) + + var wg sync.WaitGroup + for i, img := range images { + // Reset Found flag for each render pass. + img.Found = false + + // Only process if we haven't already resolved this image. + if len(img.Contents) == 0 { + wg.Add(1) + go func(idx int, img *InlineImage) { + defer wg.Done() + log.Infof("Resolving image %d: %s", idx, img.Source) + data, err := msg.ResolveCID(ctx, img.Source) + if err != nil { + log.Errorf("Failed to resolve image %q: %v", img.Source, err) + return + } + + // Use Go stdlib to decode and convert to PNG. + imgObj, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + log.Errorf("Failed to decode image %q: %v", img.Source, err) + return + } + var buf bytes.Buffer + if err := png.Encode(&buf, imgObj); err != nil { + log.Errorf("Failed to encode image %q to PNG: %v", img.Source, err) + return + } + bounds := imgObj.Bounds() + w, h := bounds.Dx(), bounds.Dy() + log.Infof("Image %d converted to PNG, size %dx%d", idx, w, h) + + msg.m.Lock() + img.Contents = data + img.PNGContents = buf.Bytes() + img.PixelWidth = w + img.PixelHeight = h + img.Width, img.Height = calculateCellOccupancy(w, h, termW, termH) + + if proto == "kitty" { + img.KittyID = allocKittyID() + log.Infof("Uploading image %d to kitty with ID %d", idx, img.KittyID) + kittyUploadImage(img.PNGContents, img.KittyID) + } + msg.m.Unlock() + }(i, img) + } + } + wg.Wait() + + for i, img := range images { + // Search for this image's specific marker in the body lines. + // Note the spaces around the marker added by htmlRender. + marker := fmt.Sprintf(" ##IMG_%d_## ", i) + for ln := 0; ln < len(lines); ln++ { + idx := strings.Index(lines[ln], marker) + if idx >= 0 { + log.Infof("Image %d marker found at line %d, col %d", i, ln, idx) + img.X = idx + img.Y = ln + img.Found = true + + // Replace the marker with spaces to preserve the layout. + lines[ln] = strings.Replace(lines[ln], marker, strings.Repeat(" ", img.Width), 1) + + // If the image takes multiple rows, insert blank padding lines below. + if img.Height > 1 { + blanks := make([]string, img.Height-1) + for b := range blanks { + blanks[b] = "" + } + newLines := make([]string, 0, len(lines)+len(blanks)) + newLines = append(newLines, lines[:ln+1]...) + newLines = append(newLines, blanks...) + newLines = append(newLines, lines[ln+1:]...) + lines = newLines + } + break + } + } + if !img.Found { + log.Debugf("Image %d marker %q not found in current body version", i, marker) + } + } + return strings.Join(lines, "\n") +} +func (msg *Message) InlineImages() []*InlineImage { + msg.m.RLock() + defer msg.m.RUnlock() + return msg.inlineImages +} + // Message is an email message. type Message struct { m sync.RWMutex @@ -115,8 +429,9 @@ type Message struct { gpgStatus *gpg.Status Response *gmail.Message - raw string - attachments []*Attachment + raw string + attachments []*Attachment + inlineImages []*InlineImage } // ThreadID returns the thread ID of the message. @@ -772,23 +1087,105 @@ func partIsAttachment(p *gmail.MessagePart) bool { return false } -func htmlRender(ctx context.Context, s string) (string, error) { - var stdout bytes.Buffer - st := time.Now() - cmd := exec.CommandContext(ctx, Lynx, "-dump", "-stdin") +var kittyNextID uint32 = 1 + +func allocKittyID() uint32 { + return atomic.AddUint32(&kittyNextID, 1) +} + +func (msg *Message) KittyUploadImage(data []byte, id uint32) { + kittyUploadImage(data, id) +} + +func kittyUploadImage(data []byte, id uint32) { + if len(data) == 0 { + return + } + payload := base64.StdEncoding.EncodeToString(data) + const chunkSize = 4096 + for offset := 0; offset < len(payload); offset += chunkSize { + end := offset + chunkSize + if end > len(payload) { + end = len(payload) + } + more := "0" + if end < len(payload) { + more = "1" + } + chunk := payload[offset:end] + if offset == 0 { + // f=100 (PNG), a=t (transmit/upload), i=ID, q=2 (quiet), m=more + fmt.Printf("\x1b_Gf=100,a=t,i=%d,q=2,m=%s;%s\x1b\\", id, more, chunk) + } else { + fmt.Printf("\x1b_Gm=%s;%s\x1b\\", more, chunk) + } + } +} + +func (msg *Message) KittyDisplayImage(id uint32, cols, rows, pxX, pxY, pxW, pxH int) string { + // a=p (put/display), i=ID, q=2 (quiet), C=1 (don't move cursor) + // c=cols, r=rows (destination size in cells) + // x,y,w,h (source sub-rectangle in pixels) + return fmt.Sprintf("\x1b_Ga=p,i=%d,q=2,C=1,c=%d,r=%d,x=%d,y=%d,w=%d,h=%d\x1b\\", id, cols, rows, pxX, pxY, pxW, pxH) +} + +func (msg *Message) ITerm2Encode(data []byte, cols, rows int) string { + // width/height in character cells (ch/lp) + b64 := base64.StdEncoding.EncodeToString(data) + return fmt.Sprintf("\x1b]1337;File=width=%dch;height=%dlp;inline=1:%s\x1b\\", cols, rows, b64) +} + +type renderImage struct { + Index int `json:"index"` + Source string `json:"source"` +} + +type renderResponse struct { + RenderedText string `json:"rendered_text"` + InlineImages []renderImage `json:"inline_images"` +} + +func htmlRender(ctx context.Context, s string, startIdx *int) (string, []*InlineImage, error) { + bin, err := exec.LookPath(cmdgRenderBinary) + if err != nil { + // Fallback: simple text rendering (just return original for now, or strip tags) + log.Infof("External renderer %q not found, falling back to plain text", cmdgRenderBinary) + return s, nil, nil + } + + cmd := exec.CommandContext(ctx, bin, "--start-index", fmt.Sprintf("%d", *startIdx)) cmd.Stdin = strings.NewReader(s) - cmd.Stdout = &stdout - if err := cmd.Run(); err != nil { - return "", err + out, err := cmd.Output() + if err != nil { + return "", nil, errors.Wrapf(err, "executing external renderer") } - log.Infof("Rendered HTML in %v", time.Since(st)) - return fmt.Sprintf("%sRendered HTML%s\n%s", display.Blue, display.Reset, stdout.String()), nil + + var resp renderResponse + if err := json.Unmarshal(out, &resp); err != nil { + return "", nil, errors.Wrapf(err, "parsing renderer JSON") + } + + var images []*InlineImage + for _, img := range resp.InlineImages { + images = append(images, &InlineImage{ + Source: img.Source, + }) + // We don't need to update startIdx here because the external tool already + // handled the indexing, but we should update it for subsequent calls. + if img.Index >= *startIdx { + *startIdx = img.Index + 1 + } + } + + res := resp.RenderedText + log.Infof("Rendered HTML with external tool %q (len %d), found %d images", cmdgRenderBinary, len(res), len(images)) + return fmt.Sprintf("%sRendered HTML%s\n%s", display.Blue, display.Reset, res), images, nil } var errNoUsablePart = fmt.Errorf("could not find message part usable as message body") // makeBodyAlt takes a multipart and tries to render the best thing it can from it. -func makeBodyAlt(ctx context.Context, part *gmail.MessagePart, preferHTML bool) (string, error) { +func makeBodyAlt(ctx context.Context, part *gmail.MessagePart, preferHTML bool, startIdx *int) (string, []*InlineImage, error) { wantT := "text/plain" acceptT := "text/html" if preferHTML { @@ -797,20 +1194,23 @@ func makeBodyAlt(ctx context.Context, part *gmail.MessagePart, preferHTML bool) var ret []string var alt []string + var allImages []*InlineImage for _, p := range part.Parts { if partIsAttachment(p) { continue } dec, err := MIMEDecode(string(p.Body.Data)) if err != nil { - return "", err + return "", nil, err } if p.MimeType == "text/html" { - dec, err = htmlRender(ctx, dec) + var images []*InlineImage + dec, images, err = htmlRender(ctx, dec, startIdx) if err != nil { - return "", errors.Wrapf(err, "rendering HTML") + return "", nil, errors.Wrapf(err, "rendering HTML") } + allImages = append(allImages, images...) } log.Debugf("Alt mimetype: %q", p.MimeType) @@ -824,10 +1224,11 @@ func makeBodyAlt(ctx context.Context, part *gmail.MessagePart, preferHTML bool) alt = append(alt, dec) } case "multipart/alternative", "multipart/related", "multipart/signed", "multipart/mixed", "message/rfc822": - t, err := makeBodyAlt(ctx, p, preferHTML) + t, images, err := makeBodyAlt(ctx, p, preferHTML, startIdx) if err != nil { - return "", err + return "", nil, err } + allImages = append(allImages, images...) // However it was rendered it should be rendered. ret = append(ret, t) alt = append(alt, t) @@ -838,34 +1239,38 @@ func makeBodyAlt(ctx context.Context, part *gmail.MessagePart, preferHTML bool) } } if len(ret) > 0 { - return strings.Join(ret, "\n"), nil + return strings.Join(ret, "\n"), allImages, nil } - return strings.Join(alt, "\n"), nil + if len(alt) > 0 { + return strings.Join(alt, "\n"), allImages, nil + } + return "", nil, errNoUsablePart } -func makeBody(ctx context.Context, part *gmail.MessagePart, preferHTML bool) (string, error) { +func makeBody(ctx context.Context, part *gmail.MessagePart, preferHTML bool, startIdx *int) (string, []*InlineImage, error) { if len(part.Parts) == 0 { log.Infof("Single part body of type %q with input len %d", part.MimeType, len(part.Body.Data)) data, err := MIMEDecode(string(part.Body.Data)) if err != nil { - return "", err + return "", nil, err } data = stripUnprintable(data) if part.MimeType == "text/html" { var err error - data, err = htmlRender(ctx, data) + var images []*InlineImage + data, images, err = htmlRender(ctx, data, startIdx) if err != nil { - return "", errors.Wrapf(err, "rendering HTML") + return "", nil, errors.Wrapf(err, "rendering HTML") } + return data, images, nil } - return data, nil + return data, nil, nil } log.Infof("Message is type %q", part.MimeType) - return makeBodyAlt(ctx, part, preferHTML) + return makeBodyAlt(ctx, part, preferHTML, startIdx) } - // GetBody returns the message body. func (msg *Message) GetBody(ctx context.Context) (string, error) { if err := msg.Preload(ctx, LevelFull); err != nil { @@ -874,6 +1279,20 @@ func (msg *Message) GetBody(ctx context.Context) (string, error) { return msg.body, nil } +// SetBody sets the message body. +func (msg *Message) SetBody(body string) { + msg.m.Lock() + defer msg.m.Unlock() + msg.body = body +} + +// SetBodyHTML sets the message body HTML. +func (msg *Message) SetBodyHTML(body string) { + msg.m.Lock() + defer msg.m.Unlock() + msg.bodyHTML = body +} + // GetBodyHTML returns the message's HTML body. func (msg *Message) GetBodyHTML(ctx context.Context) (string, error) { if err := msg.Preload(ctx, LevelFull); err != nil { @@ -1083,10 +1502,16 @@ func (msg *Message) tryGPGEncrypted(ctx context.Context) error { Data: MIMEEncode(string(t)), }, } - msg.body, err = makeBody(ctx, np, false) + idx := len(msg.inlineImages) + var images []*InlineImage + msg.body, images, err = makeBody(ctx, np, false, &idx) if err != nil { return errors.Wrap(err, "failed to decrypt") } + + + msg.inlineImages = append(msg.inlineImages, images...) + } else { _ = "TODO: handle attachment" } @@ -1156,6 +1581,18 @@ func (msg *Message) load(ctx context.Context, level DataLevel) error { } log.Debugf("Downloading message %q level %q took %v", msg.ID, level, time.Since(st)) + var bHTML, b string + var iHTML, iBody []*InlineImage + var eHTML, eBody error + if level == LevelFull { + msg.m.Lock() + msg.inlineImages = nil + msg.m.Unlock() + idx := 0 + bHTML, iHTML, eHTML = makeBody(ctx, msg2.Payload, true, &idx) + b, iBody, eBody = makeBody(ctx, msg2.Payload, false, &idx) + } + msg.m.Lock() defer msg.m.Unlock() msg.Response = msg2 @@ -1165,16 +1602,18 @@ func (msg *Message) load(ctx context.Context, level DataLevel) error { msg.headers[strings.ToLower(h.Name)] = h.Value } if level == LevelFull { - msg.bodyHTML, err = makeBody(ctx, msg.Response.Payload, true) - if err != nil && err != errNoUsablePart { - return err + if eHTML != nil && eHTML != errNoUsablePart { + return eHTML } - // TODO: do GPG stuff to HTML? + msg.bodyHTML = bHTML + msg.inlineImages = append(msg.inlineImages, iHTML...) - msg.body, err = makeBody(ctx, msg.Response.Payload, false) - if err != nil && err != errNoUsablePart { - return err + if eBody != nil && eBody != errNoUsablePart { + return eBody } + msg.body = b + msg.inlineImages = append(msg.inlineImages, iBody...) + if err := msg.tryGPGEncrypted(ctx); err != nil { msg.body = fmt.Sprintf("%sDecrypting GPG: %v%s", display.Red, err, display.Grey) } @@ -1260,11 +1699,13 @@ func (d *Draft) load(ctx context.Context, level DataLevel) error { } if level == LevelFull { var err error - d.body, err = makeBody(ctx, d.Response.Message.Payload, false) + idx := 0 + d.body, _, err = makeBody(ctx, d.Response.Message.Payload, false, &idx) if err != nil { return errors.Wrap(err, "rendering draft body") } } + return nil } diff --git a/pkg/cmdg/render_test.go b/pkg/cmdg/render_test.go new file mode 100644 index 0000000..4ab2722 --- /dev/null +++ b/pkg/cmdg/render_test.go @@ -0,0 +1,67 @@ +package cmdg + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" +) + +func TestHTMLRenderFallback(t *testing.T) { + // Set binary to something that definitely doesn't exist. + oldBinary := cmdgRenderBinary + cmdgRenderBinary = "non-existent-binary-12345" + defer func() { cmdgRenderBinary = oldBinary }() + + input := "

Hello

" + idx := 0 + got, images, err := htmlRender(context.Background(), input, &idx) + if err != nil { + t.Fatalf("htmlRender failed: %v", err) + } + + // Fallback should at least contain the original text or a stripped version. + if got == "" { + t.Error("Expected non-empty output in fallback mode") + } + if len(images) != 0 { + t.Errorf("Expected 0 images in fallback mode, got %d", len(images)) + } +} + +func TestHTMLRenderExternal(t *testing.T) { + // Try to find the binary in PATH or common locations. + bin, err := exec.LookPath(cmdgRenderBinary) + if err != nil { + // Try local relative path for development. + bin = "/home/kira/software/cmdg-image-render/cmdg-image-render" + if _, err := os.Stat(bin); err != nil { + t.Skipf("External renderer %q not found, skipping integration test", cmdgRenderBinary) + return + } + } + + // Set binary to the one we found. + oldBinary := cmdgRenderBinary + cmdgRenderBinary = bin + defer func() { cmdgRenderBinary = oldBinary }() + + input := "

Hello

" + idx := 10 + got, images, err := htmlRender(context.Background(), input, &idx) + if err != nil { + t.Fatalf("htmlRender failed: %v", err) + } + + // Should contain the marker. + if !strings.Contains(got, " ##IMG_10_## ") { + t.Errorf("Expected output to contain marker ##IMG_10_##, got %q", got) + } + if len(images) != 1 { + t.Errorf("Expected 1 image, got %d", len(images)) + } + if images[0].Source != "cid:img1" { + t.Errorf("Expected image source cid:img1, got %q", images[0].Source) + } +} diff --git a/pkg/display/screen.go b/pkg/display/screen.go index e716696..0de6cb6 100644 --- a/pkg/display/screen.go +++ b/pkg/display/screen.go @@ -100,6 +100,7 @@ type Screen struct { prevBuffer []string useCache bool cursor *cursor + PostDraw string } // NewScreen creates a new screen. @@ -221,11 +222,12 @@ func (s *Screen) Draw() { s.cursor = nil } - os := HideCursor + strings.Join(o, "") + ShowCursor + os := HideCursor + strings.Join(o, "") + ShowCursor + s.PostDraw if *useSuspend { os = Suspend + os + Resume } fmt.Print(os) + s.PostDraw = "" log.Debugf("Saved %d out of %d line while drawing. %d bytes", saved, len(s.buffer), len(os)) s.useCache = false } @@ -236,7 +238,7 @@ func (s *Screen) SetCursor(y, x int) { } var ( - stripANSIRE = regexp.MustCompile(`\033(?:\[[^a-zA-Z]*(?:[A-Za-z])?)?`) + stripANSIRE = regexp.MustCompile(`\x1b(?:\[[0-9;]*[a-zA-Z]|_G.*?\x1b\\|].*?(?:\x1b\\|\x07))?`) ) func stripANSI(s string) string { @@ -256,30 +258,43 @@ func FixedWidth(s string, w int) string { // FixedANSIWidthRight returns a fixed width version of a string, padding on the right. // The function will not strip ANSI codes, nor count them as "length". func FixedANSIWidthRight(s string, w int) string { - return fixedANSIWidthRight2(s, w, 0) -} - -func fixedANSIWidthRight2(s string, w int, recursive int) string { - // First make a guess about how many printable characters are actually ANSI. - // This will be wrong if ANSI codes get cut off. - ansiWidth := runewidth.StringWidth(s) - StringWidth(s) - - // Target width is actual width plus width of ansi codes. - targetWidth := w + ansiWidth - ret := runewidth.FillRight(runewidth.Truncate(s, targetWidth, ""), targetWidth) - - // Check if we left too much, which might happen when we cut off some ANSI codes. - if StringWidth(ret) > w { - // 3 is arbitrary. It could be that as we cut off some - // ANSI, there's still some ANSI left that will be cut off. - const maxRecursive = 3 + runes := []rune(s) + currentWidth := 0 + breakIdx := 0 + inANSI := false + + for i := 0; i < len(runes); i++ { + r := runes[i] + if r == '\x1b' { + inANSI = true + } + if inANSI { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '\\' || r == '\x07' { + if r == '\\' && i > 0 && runes[i-1] == '\x1b' { + inANSI = false + } else if r == '\x07' { + inANSI = false + } else if r != ';' && r != '?' && r != '[' && !(r >= '0' && r <= '9') { + inANSI = false + } + } + breakIdx = i + 1 + continue + } - if recursive < maxRecursive { - return fixedANSIWidthRight2(ret, w, recursive+1) + rw := runewidth.RuneWidth(r) + if currentWidth+rw > w { + break } - log.Errorf("CAN'T HAPPEN: Failed to turn %q into size %d. Returning %q, size %d", s, w, ret, StringWidth(s)) + currentWidth += rw + breakIdx = i + 1 } - return ret + + res := string(runes[:breakIdx]) + if currentWidth < w { + res += strings.Repeat(" ", w-currentWidth) + } + return res } // Printlnf sets the content of a line to be a printfed string @@ -329,3 +344,59 @@ func (s *Screen) Printf(y, x int, fmts string, args ...interface{}) { func Exit() { fmt.Println(SaveCursor + Reset + DoWrap + ResetScroll + RestoreCursor) } + +// Wrap wraps a string to a given width, being aware of ANSI escape sequences. +func Wrap(s string, w int) []string { + var lines []string + for _, l := range strings.Split(s, "\n") { + if len(l) == 0 { + lines = append(lines, "") + continue + } + for StringWidth(l) > 0 { + if StringWidth(l) <= w { + lines = append(lines, l) + break + } + + // Find the break point that results in visible width <= w. + // We iterate through runes to be safe with multi-byte chars and ANSI. + breakIdx := 0 + currentWidth := 0 + inANSI := false + runes := []rune(l) + for i := 0; i < len(runes); i++ { + r := runes[i] + if r == '\x1b' { + inANSI = true + } + if inANSI { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '\\' || r == '\x07' { + // Potential end of sequence. + // Check for Kitty/OSC terminations. + if r == '\\' && i > 0 && runes[i-1] == '\x1b' { + inANSI = false + } else if r == '\x07' { + inANSI = false + } else if r != ';' && r != '?' && r != '[' && !(r >= '0' && r <= '9') { + // Standard CSI/other ends on a letter. + inANSI = false + } + } + breakIdx = i + 1 + continue + } + + rw := runewidth.RuneWidth(r) + if currentWidth+rw > w { + break + } + currentWidth += rw + breakIdx = i + 1 + } + lines = append(lines, string(runes[:breakIdx])) + l = string(runes[breakIdx:]) + } + } + return lines +} diff --git a/render_extraction_blueprint.md b/render_extraction_blueprint.md new file mode 100644 index 0000000..81cc444 --- /dev/null +++ b/render_extraction_blueprint.md @@ -0,0 +1,54 @@ +# Blueprint: External Rendering Engine Extraction + +## Overview +This document outlines the steps to extract the C-based HTML rendering engine and MacOS integration logic from the `cmdg` repository into a standalone tool called `cmdg-render`. This satisfies the maintainer's request to reduce "code liability" in the main project. + +## 1. New Repository: `cmdg-render` +A new repository will be created to host the rendering engine. +- **Core Components:** + - `pkg/render`: The CGO wrapper and the `clib/` directory (moved from `cmdg`). + - `cmd/cmdg-render`: A simple CLI entry point. +- **CLI Interface:** + - **Input:** Reads raw HTML from `stdin`. + - **Arguments:** `--width ` (optional, default 80). + - **Output:** A structured JSON object to `stdout`. + - **Output Schema:** + ```json + { + "rendered_text": "Formatted text with ##IMG_%d_## placeholders", + "inline_images": [ + { + "index": 0, + "source": "cid:..." + } + ] + } + ``` + +## 2. Simplification of `cmdg` +Once the external tool is available, the following steps will be taken in the `cmdg` repository: +- **Deletion:** + - Remove `pkg/cmdg/clib/` directory entirely. + - Remove `pkg/cmdg/image_test.go`. + - Remove all Swift and MacOS-specific C/Go files. +- **Refactoring `pkg/cmdg/message.go`:** + - Replace the internal `htmlRender()` function. + - New `htmlRender()` will: + 1. Spawn `cmdg-render --width `. + 2. Write the HTML body to the process's `stdin`. + 3. Parse the JSON result from `stdout`. + 4. Map the `inline_images` metadata to the existing `InlineImage` struct. + +## 3. Deployment and Dependency +- `cmdg` will check for the presence of the `cmdg-render` binary in the user's `$PATH`. +- If missing, it can either: + - Fall back to basic plaintext (no HTML). + - Provide a helpful error message: "Please install cmdg-render to view HTML emails and images." + +## 4. Implementation Steps +1. **Initialize:** Create the `cmdg-render` project structure. +2. **Migrate:** Copy `clib` and related tests to the new project. +3. **Wrap:** Implement the JSON CLI wrapper in `cmd/cmdg-render/main.go`. +4. **Link:** Update `cmdg` to use the new binary. +5. **Clean:** Execute the deletion of legacy CGO code in `cmdg`. +6. **Verify:** Run the full test suite and manual verification to ensure feature parity. \ No newline at end of file