Initial push to gitea

This commit is contained in:
2026-05-10 13:37:17 -06:00
commit 54629aecad
20 changed files with 2381 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
package output
import (
"fmt"
"os/exec"
"strings"
)
// CopyToClipboard tries platform-appropriate clipboard tools and writes data
// to the first one available: wl-copy (Wayland), xclip (X11), pbcopy (macOS).
// Returns the tool name used or an error if none are available.
func CopyToClipboard(data string) (string, error) {
candidates := [][]string{
{"wl-copy"},
{"xclip", "-selection", "clipboard"},
{"pbcopy"},
}
for _, c := range candidates {
if _, err := exec.LookPath(c[0]); err != nil {
continue
}
cmd := exec.Command(c[0], c[1:]...)
cmd.Stdin = strings.NewReader(data)
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("%s: %w", c[0], err)
}
return c[0], nil
}
return "", fmt.Errorf("no clipboard tool found (tried wl-copy, xclip, pbcopy)")
}

154
internal/output/spotify.go Normal file
View File

@@ -0,0 +1,154 @@
// Package output renders summaries to user-visible formats. Markdown is
// passed through; Spotify HTML uses the small tag subset that Spotify for
// Podcasters' show-notes editor accepts (b, i, a, ul/ol/li, paragraphs).
package output
import (
"regexp"
"strings"
)
var (
reBoldStar = regexp.MustCompile(`\*\*([^*\n]+)\*\*`)
reBoldUnder = regexp.MustCompile(`__([^_\n]+)__`)
reItalicStar = regexp.MustCompile(`\*([^*\n]+)\*`)
reItalicUnder = regexp.MustCompile(`(^|[\s(])_([^_\n]+)_($|[\s).,!?;:])`)
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)\s]+)\)`)
reInlineCode = regexp.MustCompile("`([^`\n]+)`")
)
// MarkdownToSpotifyHTML converts a markdown summary into the limited HTML
// subset Spotify for Podcasters renders. Unknown markdown structures degrade
// to plain text rather than producing rejected tags.
func MarkdownToSpotifyHTML(md string) string {
lines := strings.Split(strings.ReplaceAll(md, "\r\n", "\n"), "\n")
var out strings.Builder
listKind := "" // "ul" or "ol" while we're inside a list
flushList := func() {
if listKind != "" {
out.WriteString("</" + listKind + ">\n")
listKind = ""
}
}
openList := func(kind string) {
if listKind != kind {
flushList()
out.WriteString("<" + kind + ">\n")
listKind = kind
}
}
paragraph := []string{}
flushPara := func() {
if len(paragraph) == 0 {
return
}
text := strings.Join(paragraph, " ")
out.WriteString("<p>" + inline(text) + "</p>\n")
paragraph = paragraph[:0]
}
for _, raw := range lines {
line := strings.TrimRight(raw, " \t")
trim := strings.TrimSpace(line)
// Blank line: end current paragraph/list block.
if trim == "" {
flushPara()
flushList()
continue
}
// Horizontal rule.
if trim == "---" || trim == "***" || trim == "___" {
flushPara()
flushList()
continue
}
// Heading -> bold paragraph.
if h := headingText(trim); h != "" {
flushPara()
flushList()
out.WriteString("<p><b>" + inline(h) + "</b></p>\n")
continue
}
// Blockquote -> italic paragraph.
if strings.HasPrefix(trim, "> ") {
flushPara()
flushList()
out.WriteString("<p><i>" + inline(strings.TrimPrefix(trim, "> ")) + "</i></p>\n")
continue
}
// Unordered list item.
if strings.HasPrefix(trim, "- ") || strings.HasPrefix(trim, "* ") || strings.HasPrefix(trim, "+ ") {
flushPara()
openList("ul")
out.WriteString(" <li>" + inline(trim[2:]) + "</li>\n")
continue
}
// Ordered list item like "1. text".
if item, ok := orderedItem(trim); ok {
flushPara()
openList("ol")
out.WriteString(" <li>" + inline(item) + "</li>\n")
continue
}
// Anything else: append to current paragraph.
flushList()
paragraph = append(paragraph, trim)
}
flushPara()
flushList()
return strings.TrimRight(out.String(), "\n")
}
func headingText(s string) string {
// Up to 6 leading '#' followed by a space.
hashes := 0
for hashes < len(s) && s[hashes] == '#' {
hashes++
}
if hashes == 0 || hashes > 6 || hashes >= len(s) || s[hashes] != ' ' {
return ""
}
return strings.TrimSpace(s[hashes+1:])
}
func orderedItem(s string) (string, bool) {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == 0 || i+1 >= len(s) || s[i] != '.' || s[i+1] != ' ' {
return "", false
}
return strings.TrimSpace(s[i+2:]), true
}
func inline(s string) string {
s = escapeHTML(s)
s = reInlineCode.ReplaceAllString(s, "$1")
s = reBoldStar.ReplaceAllString(s, "<b>$1</b>")
s = reBoldUnder.ReplaceAllString(s, "<b>$1</b>")
s = reItalicStar.ReplaceAllString(s, "<i>$1</i>")
s = reItalicUnder.ReplaceAllString(s, "$1<i>$2</i>$3")
s = reLink.ReplaceAllString(s, `<a href="$2">$1</a>`)
return s
}
func escapeHTML(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
)
return r.Replace(s)
}

View File

@@ -0,0 +1,67 @@
package output
import (
"strings"
"testing"
)
func TestMarkdownToSpotifyHTML(t *testing.T) {
in := `# Sermon Title
**Speaker:** Pastor Bob
**Scripture:** John 3:16
## Overview
This was a *short* message about hope. See [the site](https://example.com).
## Key Points
- First point
- Second point with **bold** text
- Third one
1. Step one
2. Step two
> A pithy quote.
`
got := MarkdownToSpotifyHTML(in)
mustContain := []string{
"<p><b>Sermon Title</b></p>",
"<b>Speaker:</b>",
"<p><b>Overview</b></p>",
"<i>short</i>",
`<a href="https://example.com">the site</a>`,
"<ul>",
"<li>First point</li>",
"<li>Second point with <b>bold</b> text</li>",
"</ul>",
"<ol>",
"<li>Step one</li>",
"</ol>",
"<p><i>A pithy quote.</i></p>",
}
for _, s := range mustContain {
if !strings.Contains(got, s) {
t.Errorf("expected output to contain %q\n--- got ---\n%s", s, got)
}
}
mustNotContain := []string{"<h1>", "<h2>", "<blockquote>", "**", "##"}
for _, s := range mustNotContain {
if strings.Contains(got, s) {
t.Errorf("did not expect output to contain %q\n--- got ---\n%s", s, got)
}
}
}
func TestEscapesHTML(t *testing.T) {
got := MarkdownToSpotifyHTML("A <script>tag</script> & ampersand")
if strings.Contains(got, "<script>") {
t.Errorf("unescaped <script>: %s", got)
}
if !strings.Contains(got, "&amp;") {
t.Errorf("expected &amp; in: %s", got)
}
}