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
}

2
go.mod
View file

@ -4,8 +4,6 @@ go 1.25.0
require (
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
)

69
main.go
View file

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