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 // GemlogEntry represents a single gemlog post entry
type GemlogEntry struct { type GemlogEntry struct {
ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Slug string `json:"slug"` Slug string `json:"slug"`
Date time.Time `json:"date"` Date time.Time `json:"date"`
Tags []string `json:"tags"`
Gemtxt string `json:"gemtxt"` Gemtxt string `json:"gemtxt"`
} }

View file

@ -1,218 +1,50 @@
package gemlog package gemlog
import ( import (
"context" "bytes"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"strings" "io"
"time" "net/http"
_ "github.com/go-kivik/couchdb/v3" // CouchDB driver
"github.com/go-kivik/kivik/v3"
) )
// Post represents a blog post document in CouchDB func SaveGemlogEntry(config *Config, entry *GemlogEntry) error {
type Post struct { url := fmt.Sprintf("%s:%d/gemlog/", config.CouchDB.Host, config.CouchDB.Port)
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"`
}
// CouchDBClient wraps the CouchDB connection and operations // Marshal the entry struct to JSON
type CouchDBClient struct { jsonData, err := json.Marshal(entry)
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)
if err != nil { 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 req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
exists, err := client.DBExists(context.TODO(), dbName)
if err != nil { 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 { // Encode username:password for Basic Auth
err = client.CreateDB(context.TODO(), dbName) 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")
res, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create database: %w", err) return fmt.Errorf("failed to send request: %w", err)
}
} }
defer res.Body.Close()
db := client.DB(context.TODO(), dbName) body, err := io.ReadAll(res.Body)
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)
if err != nil { if err != nil {
return fmt.Errorf("failed to save post: %w", err) return fmt.Errorf("failed to read response body: %w", err)
} }
// Update the revision if res.StatusCode < 200 || res.StatusCode >= 300 {
post.Rev = rev return fmt.Errorf("unexpected status code %d: %s", res.StatusCode, string(body))
return nil
} }
// GetPost retrieves a post by ID // fmt.Println(res)
func (c *CouchDBClient) GetPost(id string) (*Post, error) { // fmt.Println(string(body))
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,
})
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)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating posts: %w", err)
}
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)
}
return nil 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" "fmt"
"os" "os"
"os/exec" "os/exec"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"gopkg.in/yaml.v3"
) )
func WritePostCMD() tea.Cmd { func WritePostCMD(config *Config) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
// Create a temporary file // Create a temporary file
tmpFile, err := os.CreateTemp("/tmp", "gemlog-*.md") 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)} 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 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 func parsePost(post string) (GemlogEntry, error) {
// error interface on the message. // split post on new lines
// func (e ErrorMsg) Error() string { return e.err.Error() } lines := strings.Split(post, "\n")
// WriteAction opens a shell editor and prints the output to console // Find the separator line "---"
func write() (string, error) { separatorIndex := -1
// Get the editor from environment variable, default to vi for i, line := range lines {
editor := os.Getenv("EDITOR") if strings.TrimSpace(line) == "---" {
if editor == "" { separatorIndex = i
editor = "vim" break
}
} }
// Create a temporary file if separatorIndex == -1 {
tmpFile, err := os.CreateTemp("", "gemlog-*.md") 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 { if err != nil {
return "", fmt.Errorf("failed to create temporary file: %w", err) return GemlogEntry{}, fmt.Errorf("failed to parse date: %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)
} }
// Read the contents of the file // Parse tags (comma-separated)
content, err := os.ReadFile(tmpFile.Name()) var tags []string
if err != nil { if frontmatter.Tags != "" {
return "", fmt.Errorf("failed to read file contents: %w", err) tagParts := strings.Split(frontmatter.Tags, ",")
for _, tag := range tagParts {
trimmed := strings.TrimSpace(tag)
if trimmed != "" {
tags = append(tags, trimmed)
}
}
} }
contentStr := string(content) // Join body lines
return contentStr, nil body := strings.Join(bodyLines, "\n")
return GemlogEntry{
Title: frontmatter.Title,
Date: date,
Slug: frontmatter.Slug,
Tags: tags,
Gemtxt: body,
}, nil
} }

2
go.mod
View file

@ -4,8 +4,6 @@ go 1.25.0
require ( require (
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/go-kivik/couchdb/v3 v3.4.1
github.com/go-kivik/kivik/v3 v3.2.4
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )

67
main.go
View file

@ -17,32 +17,35 @@ const (
Delete Action = "delete" Delete Action = "delete"
) )
var actions = []Action{Write, Read, Edit, Delete}
func TODOCmd() tea.Msg { func TODOCmd() tea.Msg {
return gemlog.Notification("This action has not been implemented yet. Try another.") return gemlog.Notification("This action has not been implemented yet. Try another.")
} }
var mainCommands = map[Action]tea.Cmd{ type uiState struct {
Write: gemlog.WritePostCMD(), notification string
Read: TODOCmd, cursor int
Edit: TODOCmd, errorTxt string
Delete: TODOCmd, }
type context struct {
config *gemlog.Config
} }
type model struct { type model struct {
// ui state ui uiState
notification string context *context
cursor int
actions []Action
errorTxt string
// deps
config *gemlog.Config
} }
func initialModel(config *gemlog.Config) model { func initialModel(config *gemlog.Config) model {
return model{ return model{
actions: []Action{Write, Read, Edit, Delete}, ui: uiState{
// actions: []Action{Write, Read, Edit, Delete},
},
context: &context{
config: config, config: config,
},
} }
} }
@ -52,22 +55,34 @@ func (m model) Init() tea.Cmd {
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case gemlog.ErrorMsg:
m.ui.errorTxt = fmt.Sprintf("%s\n\n", fmt.Errorf("%s", msg))
case gemlog.Notification: case gemlog.Notification:
m.notification = fmt.Sprintf("%s\n\n", string(msg)) m.ui.notification = fmt.Sprintf("%s\n\n", string(msg))
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c", "q": case "ctrl+c", "q":
return m, tea.Quit return m, tea.Quit
case "up", "k": case "up", "k":
if m.cursor > 0 { if m.ui.cursor > 0 {
m.cursor-- m.ui.cursor--
} }
case "down", "j": case "down", "j":
if m.cursor < len(m.actions)-1 { if m.ui.cursor < len(actions)-1 {
m.cursor++ m.ui.cursor++
} }
case "enter", " ": case "enter", " ":
return m, mainCommands[m.actions[m.cursor]] action := actions[m.ui.cursor]
switch action {
case Write:
return m, gemlog.WritePostCMD(m.context.config)
case Read:
return m, TODOCmd
case Edit:
return m, TODOCmd
case Delete:
return m, TODOCmd
}
} }
} }
@ -75,19 +90,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m model) View() string { func (m model) View() string {
if len(m.errorTxt) > 0 { if len(m.ui.errorTxt) > 0 {
return m.errorTxt return m.ui.errorTxt
} }
s := "" s := ""
if m.notification != "" { if m.ui.notification != "" {
s += m.notification s += m.ui.notification
} else { } else {
s += "Welcome to gemlog cli!\n\nWhat post action would you like to take?\n\n" s += "Welcome to gemlog cli!\n\nWhat post action would you like to take?\n\n"
} }
for i, action := range m.actions { for i, action := range actions {
cursor := " " cursor := " "
if m.cursor == i { if m.ui.cursor == i {
cursor = ">" cursor = ">"
} }
s += fmt.Sprintf("%s %s\n", cursor, action) s += fmt.Sprintf("%s %s\n", cursor, action)