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
|
// 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
220
gemlog/db.go
220
gemlog/db.go
|
|
@ -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
|
|
||||||
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
|
// fmt.Println(res)
|
||||||
if err := row.ScanDoc(&post); err != nil {
|
// fmt.Println(string(body))
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
102
gemlog/write.go
102
gemlog/write.go
|
|
@ -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
2
go.mod
|
|
@ -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
67
main.go
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue