get write action working and entries saving to db
This commit is contained in:
parent
472c1b36a7
commit
09fb974bd2
5 changed files with 136 additions and 257 deletions
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
218
gemlog/db.go
218
gemlog/db.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
102
gemlog/write.go
102
gemlog/write.go
|
|
@ -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
2
go.mod
|
|
@ -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
69
main.go
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue