personal-gemini-capsule/internal/microblog/microblog.go
Travis Shears d6d00f6dc9
add page for single micro-blog posts
", p.ID, p.Source, p.ID))
posted: %s
", p.Timestamp.Format("2006-01-02 15:04")))

")

", post.Title))

",

")

---

")
")
")

", post.Title))
")
")
2025-10-18 22:38:01 +02:00

319 lines
8.6 KiB
Go

package microblog
import (
_ "embed"
"encoding/json"
"fmt"
"gemini_site/internal/pocketbase"
"log/slog"
"regexp"
"strconv"
"strings"
"time"
gemini "github.com/kulak/gemini"
)
type source string
const (
sourcePleroma source = "pleroma"
sourceBlueSky source = "blue_sky"
sourceMastodon source = "mastodon"
sourcePixelfed source = "pixelfed"
sourceNostr source = "nostr"
)
// Post represents a single blog post
type post struct {
ID string
RemoteID string
Content string
// TODO: add support for images, must extend the pocketbase query
// Images []string
Timestamp time.Time
Source source
}
// PBPost represents a microblog post from PocketBase
type pbPost struct {
ID string `json:"id"`
RemoteID string `json:"remoteId"`
Source Source `json:"source"`
FullPost json.RawMessage `json:"fullPost"`
Posted string `json:"posted"`
}
type nostrPost struct {
Content string `json:"content"`
}
// MicroBlog manages blog posts
type MicroBlog struct {
pbClient *pocketbase.PocketBaseClient
}
// NewMicroBlog creates a new microblog instance
func NewMicroBlog(pbClient *pocketbase.PocketBaseClient) *MicroBlog {
mb := &MicroBlog{
pbClient: pbClient,
}
return mb
}
func escapeHashTags(content string) string {
// TODO: maybe prettier way to do this with a code
return strings.ReplaceAll(content, "#", "\\#")
}
func removeHTMLTags(content string) string {
// Strip simple HTML tags. Paragraphs become newlines.
// Replace opening <p> tags with nothing, closing </p> with newline.
rePStart := regexp.MustCompile(`(?i)<p[^>]*>`)
rePEnd := regexp.MustCompile(`(?i)</p>`)
content = rePStart.ReplaceAllString(content, "")
content = rePEnd.ReplaceAllString(content, "\n")
// Convert <br> to newline
reBr := regexp.MustCompile(`(?i)<br\s*/?>`)
content = reBr.ReplaceAllString(content, "\n")
// Remove any remaining tags
reTags := regexp.MustCompile(`(?i)<[^>]+>`)
content = reTags.ReplaceAllString(content, "")
// Normalize whitespace: trim and collapse excessive blank lines
content = strings.TrimSpace(content)
reMultiNewlines := regexp.MustCompile(`\n{3,}`)
return reMultiNewlines.ReplaceAllString(content, "\n\n")
}
func transformNostrPost(p pbPost) post {
var nostrPost nostrPost
if err := json.Unmarshal(p.FullPost, &nostrPost); err != nil {
slog.Error("Problem unmarshalling nostr post", "error", err)
return post{}
}
content := nostrPost.Content
content = escapeHashTags(content)
return post{
ID: p.ID,
RemoteID: p.RemoteID,
Content: content,
Timestamp: time.Now(),
Source: source(p.Source),
}
}
func transformBlueSkyPost(p pbPost) post {
var fullPost struct {
Record struct {
Text string `json:"text"`
} `json:"record"`
}
if err := json.Unmarshal(p.FullPost, &fullPost); err != nil {
slog.Error("Problem unmarshalling bluesky post", "error", err)
return post{}
}
content := fullPost.Record.Text
content = escapeHashTags(content)
return post{
ID: p.ID,
RemoteID: p.RemoteID,
Content: content,
Timestamp: time.Now(),
Source: source(p.Source),
}
}
func transformMastodonPost(p pbPost) post {
var fullPost struct {
Content string `json:"content"`
Reblog *struct {
Content string `json:"content"`
Account *struct {
Acct string `json:"acct"`
} `json:"account"`
} `json:"reblog"`
}
var content string
if err := json.Unmarshal(p.FullPost, &fullPost); err != nil {
slog.Error("Problem unmarshalling mastodon post", "error", err)
return post{}
}
if fullPost.Reblog != nil {
content = fmt.Sprintf(
"reblogged post from %s:\n%s\n",
fullPost.Reblog.Account.Acct,
fullPost.Reblog.Content,
)
} else {
content = fullPost.Content
}
content = escapeHashTags(content)
content = removeHTMLTags(content)
return post{
ID: p.ID,
RemoteID: p.RemoteID,
Content: content,
Timestamp: time.Now(),
Source: source(p.Source),
}
}
// GetRecentPosts returns the most recent posts
func (mb *MicroBlog) GetPost(id string) (post, error) {
res, err := mb.pbClient.GetRecord("micro_blog_posts", id)
if err != nil {
return post{}, fmt.Errorf("failed to fetch post from pocketbase: %w", err)
}
var remotePost pbPost
if err := json.Unmarshal(*res, &remotePost); err != nil {
return post{}, fmt.Errorf("failed to decode response: %w", err)
}
slog.Info("post from pocketbase", "remotePost", remotePost)
switch remotePost.Source {
case SourceMastodon:
return transformMastodonPost(remotePost), nil
case SourceNostr:
return transformNostrPost(remotePost), nil
case SourceBlueSky:
return transformBlueSkyPost(remotePost), nil
}
return post{},
fmt.Errorf("Don't know how to transform a post with that source")
}
// GetRecentPosts returns the most recent posts
func (mb *MicroBlog) GetRecentPosts(limit int, page int) ([]post, error) {
res, err := mb.pbClient.GetList("micro_blog_posts", page, limit, "-posted")
if err != nil {
return nil, fmt.Errorf("failed to fetch posts from pocketbase: %w", err)
}
var rawPosts []pbPost
if err := json.Unmarshal(res.Items, &rawPosts); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
slog.Info("Posts from pocketbase", "count", len(rawPosts))
var filteredPosts []post
for _, p := range rawPosts {
switch p.Source {
case SourceNostr:
filteredPosts = append(filteredPosts, transformNostrPost(p))
case SourceMastodon:
filteredPosts = append(filteredPosts, transformMastodonPost(p))
case SourceBlueSky:
filteredPosts = append(filteredPosts, transformBlueSkyPost(p))
}
}
return filteredPosts, nil
}
func formatContent(content string) string {
return replaceLinks(content)
}
func replaceLinks(content string) string {
// Regex: ^(https?://\S+)
// ^ : start of line
// https? : http or https
// :// : literal
// \S+ : one or more non-whitespace characters
re := regexp.MustCompile(`(?m)^(https?://\S+)`)
// Replace each match with "=> <url>"
return re.ReplaceAllStringFunc(content, func(match string) string {
return "=> " + match
})
}
// HandleBlogRequest handles Gemini requests for the microblog
func (mb *MicroBlog) HandleBlogRequest(w gemini.ResponseWriter, req *gemini.Request) {
path := req.URL.Path
switch {
case path == "/microblog" || path == "/microblog/":
mb.serveIndex(w, req, 1)
case strings.HasPrefix(path, "/microblog/page/"):
pageNum := strings.TrimPrefix(path, "/microblog/page/")
num, err := strconv.Atoi(pageNum)
if err != nil {
w.WriteStatusMsg(gemini.StatusBadRequest, "Invalid page number")
return
}
mb.serveIndex(w, req, num)
case strings.HasPrefix(path, "/microblog/post/"):
postID := strings.TrimPrefix(path, "/microblog/post/")
mb.servePost(w, req, postID)
default:
w.WriteStatusMsg(gemini.StatusNotFound, "Page not found")
}
}
func drawPost(builder *strings.Builder, p post) {
builder.WriteString(fmt.Sprintf("=> /microblog/post/%s 🖋️ %s post: %s \n", p.ID, p.Source, p.ID))
builder.WriteString(formatContent(p.Content))
builder.WriteString(fmt.Sprintf("\nposted: %s\n", p.Timestamp.Format("2006-01-02 15:04")))
builder.WriteString("\n\n")
}
//go:embed microblog.gmi
var pageContnet string
// serveBlogIndex serves the main blog page with recent posts
func (mb *MicroBlog) serveIndex(w gemini.ResponseWriter, req *gemini.Request, pageNum int) {
w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini")
var content strings.Builder
if pageNum == 1 {
content.Write([]byte(pageContnet))
content.WriteString("\n")
}
posts, err := mb.GetRecentPosts(20, pageNum)
if err != nil {
content.WriteString("Error fetching posts: " + err.Error() + "\n\n")
}
if len(posts) == 0 {
content.WriteString("No posts found.\n\n")
} else {
content.WriteString("## Posts\n\n")
for _, post := range posts {
drawPost(&content, post)
}
}
content.WriteString("## Nav\n\n")
// content.WriteString("=> /blog/new Write a new post\n")
content.WriteString(fmt.Sprintf("=> /microblog/page/%d Next page\n", pageNum+1))
content.WriteString("=> / Back to home\n")
w.WriteBody([]byte(content.String()))
}
// // servePost serves a single blog post
func (mb *MicroBlog) servePost(w gemini.ResponseWriter, req *gemini.Request, postID string) {
post, err := mb.GetPost(postID)
if err != nil {
slog.Error("Problem getting post", "error", err)
w.WriteStatusMsg(gemini.StatusNotFound, "Problem getting post")
return
}
w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini")
var content strings.Builder
// content.WriteString(fmt.Sprintf("# %s\n\n", post.Title))
drawPost(&content, post)
content.WriteString("=> /microblog Back to posts\n")
content.WriteString("=> / Back to home\n")
w.WriteBody([]byte(content.String()))
}