package microblog import ( "encoding/json" "fmt" "gemini_site/internal/pocketbase" "log/slog" "os" "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 } // // Add some sample posts // mb.addSamplePosts() // return mb // } // addSamplePosts adds some initial content // func (mb *MicroBlog) addSamplePosts() { // samplePosts := []Post{ // { // ID: "1", // Title: "Welcome to the Gemini Microblog", // Content: "This is the first post on our Gemini-powered microblog! It's simple, fast, and distraction-free.", // Author: "Admin", // Timestamp: time.Now().Add(-2 * time.Hour), // }, // { // ID: "2", // Title: "The Beauty of Simplicity", // Content: "Gemini protocol encourages us to focus on content over presentation. This microblog embodies that philosophy.", // Author: "Admin", // Timestamp: time.Now().Add(-1 * time.Hour), // }, // } // mb.posts = append(mb.posts, samplePosts...) // } // AddPost adds a new post to the blog // func (mb *MicroBlog) AddPost(title, content, author string) string { // id := fmt.Sprintf("%d", time.Now().Unix()) // post := Post{ // ID: id, // Title: title, // Content: content, // Author: author, // Timestamp: time.Now(), // } // mb.posts = append(mb.posts, post) // return id // } // GetPost retrieves a post by ID // // func (mb *MicroBlog) GetPost(id string) (*Post, bool) { // for _, post := range mb.posts { // if post.ID == id { // return &post, true // } // } // return nil, false // } 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 = strings.ReplaceAll(content, "#", "\\#") // content = strings.ReplaceAll(content, "\t", "\\t") 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 { if p.Source == SourceNostr { filteredPosts = append(filteredPosts, transformNostrPost(p)) // var nostrPost nostrPost // if err := json.Unmarshal(p.FullPost, &nostrPost); err != nil { // slog.Error("Problem unmarshalling nostr post", "error", err) // continue // } // filteredPosts = append(filteredPosts, post{ // ID: p.ID, // RemoteID: p.RemoteID, // Content: nostrPost.Content, // Timestamp: time.Now(), // }) continue } } return filteredPosts, nil } 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 "=> " 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("+------------------------------------------+\n") content := replaceLinks(p.Content) builder.WriteString(content) builder.WriteString("\n") builder.WriteString(fmt.Sprintf("source: %s, id: %s...\n", p.Source, p.RemoteID[:10])) builder.WriteString("+------------------------------------------+\n\n\n") // builder.WriteString(fmt.Sprintf("=> /blog/post/%s %s\n", p.ID, p.Title)) // builder.WriteString(fmt.Sprintf(" By %s on %s\n\n", // p.Timestamp.Format("2006-01-02 15:04"))) } // 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 // content.WriteString("# Gemini Microblog\n\n") // content.WriteString("Here are my microblog posts from various plantforms\n") // Read and include the contents of ../../pages/microblog.gmi if pageNum == 1 { page, err := os.ReadFile("./pages/microblog.gmi") if err != nil { slog.Error("Problem reading microblog page", "error", err) return } content.Write(page) 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())) // }