From 09fb974bd28df20f9178527233b323b1a262d6a6 Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Wed, 1 Oct 2025 09:21:34 +0200 Subject: [PATCH] get write action working and entries saving to db --- gemlog/core.go | 2 +- gemlog/db.go | 218 ++++++------------------------------------------ gemlog/write.go | 102 ++++++++++++++-------- go.mod | 2 - main.go | 69 +++++++++------ 5 files changed, 136 insertions(+), 257 deletions(-) diff --git a/gemlog/core.go b/gemlog/core.go index d5f7e06..b4efb6e 100644 --- a/gemlog/core.go +++ b/gemlog/core.go @@ -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"` } diff --git a/gemlog/db.go b/gemlog/db.go index c6bc6dd..ecdb93f 100644 --- a/gemlog/db.go +++ b/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 -} diff --git a/gemlog/write.go b/gemlog/write.go index 4adadf0..3c534f8 100644 --- a/gemlog/write.go +++ b/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 } diff --git a/go.mod b/go.mod index 291d571..dc509a0 100644 --- a/go.mod +++ b/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 ) diff --git a/main.go b/main.go index 11da056..edf1c94 100644 --- a/main.go +++ b/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)