get write action working and entries saving to db

This commit is contained in:
Travis Shears 2025-10-01 09:21:34 +02:00
parent 472c1b36a7
commit 09fb974bd2
5 changed files with 136 additions and 257 deletions

View file

@ -9,10 +9,10 @@ import (
// GemlogEntry represents a single gemlog post entry
type GemlogEntry struct {
ID string `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Date time.Time `json:"date"`
Tags []string `json:"tags"`
Gemtxt string `json:"gemtxt"`
}

View file

@ -1,218 +1,50 @@
package gemlog
import (
"context"
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
_ "github.com/go-kivik/couchdb/v3" // CouchDB driver
"github.com/go-kivik/kivik/v3"
"io"
"net/http"
)
// Post represents a blog post document in CouchDB
type Post struct {
ID string `json:"_id,omitempty"`
Rev string `json:"_rev,omitempty"`
Title string `json:"title"`
Date time.Time `json:"date"`
Slug string `json:"slug"`
Tags []string `json:"tags"`
Content string `json:"content"`
Created time.Time `json:"created"`
Modified time.Time `json:"modified"`
}
func SaveGemlogEntry(config *Config, entry *GemlogEntry) error {
url := fmt.Sprintf("%s:%d/gemlog/", config.CouchDB.Host, config.CouchDB.Port)
// CouchDBClient wraps the CouchDB connection and operations
type CouchDBClient struct {
client *kivik.Client
db *kivik.DB
}
// NewCouchDBClient creates a new CouchDB client connection
func NewCouchDBClient(url, dbName string) (*CouchDBClient, error) {
client, err := kivik.New("couch", url)
// Marshal the entry struct to JSON
jsonData, err := json.Marshal(entry)
if err != nil {
return nil, fmt.Errorf("failed to create CouchDB client: %w", err)
return fmt.Errorf("failed to marshal entry: %w", err)
}
// Check if database exists, create if it doesn't
exists, err := client.DBExists(context.TODO(), dbName)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to check if database exists: %w", err)
return fmt.Errorf("failed to create request: %w", err)
}
if !exists {
err = client.CreateDB(context.TODO(), dbName)
if err != nil {
return nil, fmt.Errorf("failed to create database: %w", err)
}
}
// Encode username:password for Basic Auth
auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", config.CouchDB.User, config.CouchDB.Password)))
req.Header.Add("authorization", fmt.Sprintf("Basic %s", auth))
req.Header.Add("content-type", "application/json")
db := client.DB(context.TODO(), dbName)
return &CouchDBClient{
client: client,
db: db,
}, nil
}
// SavePost saves a post to CouchDB
func (c *CouchDBClient) SavePost(post *Post) error {
ctx := context.TODO()
// Set timestamps
now := time.Now()
if post.Created.IsZero() {
post.Created = now
}
post.Modified = now
// Generate ID if not set (use slug-timestamp)
if post.ID == "" {
post.ID = fmt.Sprintf("%s-%d", post.Slug, now.Unix())
}
// Save the document
rev, err := c.db.Put(ctx, post.ID, post)
res, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to save post: %w", err)
return fmt.Errorf("failed to send request: %w", err)
}
defer res.Body.Close()
// Update the revision
post.Rev = rev
return nil
}
// GetPost retrieves a post by ID
func (c *CouchDBClient) GetPost(id string) (*Post, error) {
ctx := context.TODO()
row := c.db.Get(ctx, id)
if row.Err != nil {
return nil, fmt.Errorf("failed to get post: %w", row.Err)
}
var post Post
if err := row.ScanDoc(&post); err != nil {
return nil, fmt.Errorf("failed to scan post document: %w", err)
}
return &post, nil
}
// GetAllPosts retrieves all posts from the database
func (c *CouchDBClient) GetAllPosts() ([]*Post, error) {
ctx := context.TODO()
rows, err := c.db.AllDocs(ctx, kivik.Options{
"include_docs": true,
})
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to get all posts: %w", err)
}
defer rows.Close()
var posts []*Post
for rows.Next() {
var post Post
if err := rows.ScanDoc(&post); err != nil {
continue // Skip invalid documents
}
posts = append(posts, &post)
return fmt.Errorf("failed to read response body: %w", err)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating posts: %w", err)
if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("unexpected status code %d: %s", res.StatusCode, string(body))
}
return posts, nil
}
// UpdatePost updates an existing post
func (c *CouchDBClient) UpdatePost(post *Post) error {
if post.ID == "" || post.Rev == "" {
return fmt.Errorf("post ID and revision are required for updates")
}
post.Modified = time.Now()
return c.SavePost(post)
}
// DeletePost deletes a post by ID and revision
func (c *CouchDBClient) DeletePost(id, rev string) error {
ctx := context.TODO()
_, err := c.db.Delete(ctx, id, rev)
if err != nil {
return fmt.Errorf("failed to delete post: %w", err)
}
// fmt.Println(res)
// fmt.Println(string(body))
return nil
}
// ParseGeminiPost parses a gemini post content and returns a Post struct
func ParseGeminiPost(content string) (*Post, error) {
lines := strings.Split(content, "\n")
var post Post
var contentStart int
inFrontmatter := false
for i, line := range lines {
line = strings.TrimSpace(line)
// Check for frontmatter separator
if line == "---" {
if !inFrontmatter {
inFrontmatter = true
continue
} else {
// End of frontmatter
contentStart = i + 1
break
}
}
if !inFrontmatter {
continue
}
// Parse frontmatter
if strings.Contains(line, ":") {
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "title":
post.Title = value
case "date":
if date, err := time.Parse("2006-01-02", value); err == nil {
post.Date = date
}
case "slug":
post.Slug = value
case "tags":
// Parse comma-separated tags
if value != "" {
tagList := strings.Split(value, ",")
for _, tag := range tagList {
post.Tags = append(post.Tags, strings.TrimSpace(tag))
}
}
}
}
}
// Extract content after frontmatter
if contentStart < len(lines) {
post.Content = strings.Join(lines[contentStart:], "\n")
}
return &post, nil
}

View file

@ -4,11 +4,14 @@ import (
"fmt"
"os"
"os/exec"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"gopkg.in/yaml.v3"
)
func WritePostCMD() tea.Cmd {
func WritePostCMD(config *Config) tea.Cmd {
return func() tea.Msg {
// Create a temporary file
tmpFile, err := os.CreateTemp("/tmp", "gemlog-*.md")
@ -54,50 +57,81 @@ func WritePostCMD() tea.Cmd {
return ErrorMsg{fmt.Errorf("failed to read file contents: %w", readErr)}
}
gemlogEntry, err := parsePost(string(content))
if err != nil {
return ErrorMsg{fmt.Errorf("failed to parse post: %w", err)}
}
if err := SaveGemlogEntry(config, &gemlogEntry); err != nil {
return ErrorMsg{fmt.Errorf("failed to save gemlog entry: %w", err)}
}
// Return success with the content
return Notification(fmt.Sprintf("Post created with content: %s", string(content)))
return Notification(fmt.Sprintf("Post created: \ngemini://travisshears.com/gemlog/%s\n\n", gemlogEntry.Slug))
})()
}
}
// For messages that contain errors it's often handy to also implement the
// error interface on the message.
// func (e ErrorMsg) Error() string { return e.err.Error() }
func parsePost(post string) (GemlogEntry, error) {
// split post on new lines
lines := strings.Split(post, "\n")
// WriteAction opens a shell editor and prints the output to console
func write() (string, error) {
// Get the editor from environment variable, default to vi
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vim"
// Find the separator line "---"
separatorIndex := -1
for i, line := range lines {
if strings.TrimSpace(line) == "---" {
separatorIndex = i
break
}
}
// Create a temporary file
tmpFile, err := os.CreateTemp("", "gemlog-*.md")
if separatorIndex == -1 {
return GemlogEntry{}, fmt.Errorf("no frontmatter separator '---' found")
}
// Extract frontmatter and body
frontmatterLines := lines[:separatorIndex]
bodyLines := lines[separatorIndex+1:]
// Parse frontmatter YAML
frontmatterYAML := strings.Join(frontmatterLines, "\n")
var frontmatter struct {
Title string `yaml:"title"`
Date string `yaml:"date"`
Slug string `yaml:"slug"`
Tags string `yaml:"tags"`
}
if err := yaml.Unmarshal([]byte(frontmatterYAML), &frontmatter); err != nil {
return GemlogEntry{}, fmt.Errorf("failed to parse frontmatter: %w", err)
}
// Parse date
date, err := time.Parse("2006-01-02", strings.TrimSpace(frontmatter.Date))
if err != nil {
return "", fmt.Errorf("failed to create temporary file: %w", err)
}
defer os.Remove(tmpFile.Name()) // Clean up
tmpFile.Close()
// Open the editor with the temporary file
cmd := exec.Command(editor, tmpFile.Name())
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
return "", fmt.Errorf("editor command failed: %w", err)
return GemlogEntry{}, fmt.Errorf("failed to parse date: %w", err)
}
// Read the contents of the file
content, err := os.ReadFile(tmpFile.Name())
if err != nil {
return "", fmt.Errorf("failed to read file contents: %w", err)
// Parse tags (comma-separated)
var tags []string
if frontmatter.Tags != "" {
tagParts := strings.Split(frontmatter.Tags, ",")
for _, tag := range tagParts {
trimmed := strings.TrimSpace(tag)
if trimmed != "" {
tags = append(tags, trimmed)
}
}
}
contentStr := string(content)
return contentStr, nil
// Join body lines
body := strings.Join(bodyLines, "\n")
return GemlogEntry{
Title: frontmatter.Title,
Date: date,
Slug: frontmatter.Slug,
Tags: tags,
Gemtxt: body,
}, nil
}