Initial push to gitea
This commit is contained in:
30
internal/output/clipboard.go
Normal file
30
internal/output/clipboard.go
Normal 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
154
internal/output/spotify.go
Normal 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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
)
|
||||
return r.Replace(s)
|
||||
}
|
||||
67
internal/output/spotify_test.go
Normal file
67
internal/output/spotify_test.go
Normal 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, "&") {
|
||||
t.Errorf("expected & in: %s", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user