")
")
{3,}`)
")
%s
",
", p.Source, p.ID))
posted: %s
", p.Timestamp.Format("2006-01-02 15:04")))
")
299 lines
8 KiB
Go
299 lines
8 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) 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", "rawPosts", 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("=> / 🖋️ %s post: %s \n", 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, found := mb.GetPost(postID)
|
|
// if !found {
|
|
// w.WriteStatusMsg(gemini.StatusNotFound, "Post not found")
|
|
// return
|
|
// }
|
|
|
|
// w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini")
|
|
|
|
// var content strings.Builder
|
|
// content.WriteString(fmt.Sprintf("# %s\n\n", post.Title))
|
|
// content.WriteString(fmt.Sprintf("By %s on %s\n\n",
|
|
// post.Author,
|
|
// post.Timestamp.Format("2006-01-02 15:04")))
|
|
// content.WriteString("---\n\n")
|
|
// content.WriteString(post.Content)
|
|
// content.WriteString("\n\n---\n\n")
|
|
// content.WriteString("=> /blog Back to blog\n")
|
|
// content.WriteString("=> / Back to home\n")
|
|
|
|
// w.WriteBody([]byte(content.String()))
|
|
// }
|