diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b14c548 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +debug.log diff --git a/bruno/Gemlog/List Gemlogs for CLI.bru b/bruno/Gemlog/List Gemlogs for CLI.bru new file mode 100644 index 0000000..eda7653 --- /dev/null +++ b/bruno/Gemlog/List Gemlogs for CLI.bru @@ -0,0 +1,20 @@ +meta { + name: List Gemlogs for CLI + type: http + seq: 3 +} + +get { + url: http://eisenhorn:5023/gemlog/_design/gemlog-cli/_view/list + body: json + auth: basic +} + +auth:basic { + username: gemlog-cli + password: {{pw}} +} + +settings { + encodeUrl: true +} diff --git a/gemlog/common.go b/gemlog/common.go index 30c7d6c..e7d44f1 100644 --- a/gemlog/common.go +++ b/gemlog/common.go @@ -2,3 +2,4 @@ package gemlog type Notification string type ErrorMsg struct{ err error } +type GemLogsLoaded struct{ Logs []GemlogListEntry } diff --git a/gemlog/core.go b/gemlog/core.go index b4efb6e..4501a97 100644 --- a/gemlog/core.go +++ b/gemlog/core.go @@ -16,6 +16,13 @@ type GemlogEntry struct { Gemtxt string `json:"gemtxt"` } +type GemlogListEntry struct { + Title string `json:"title"` + Slug string `json:"slug"` + Date time.Time `json:"date"` + ID string `json:"id"` +} + // NewUUID generates a new UUID v4 using crypto/rand func NewUUID() (string, error) { uuid := make([]byte, 16) diff --git a/gemlog/db.go b/gemlog/db.go index ecdb93f..cc14979 100644 --- a/gemlog/db.go +++ b/gemlog/db.go @@ -6,9 +6,77 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" + "time" ) +func genBasicAuthHeader(user, password string) string { + auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, password))) + return fmt.Sprintf("Basic %s", auth) +} + +func listGemLogs(config *Config) ([]GemlogListEntry, error) { + slog.Info("Listing gemlogs from couchdb") + url := fmt.Sprintf("%s:%d/gemlog/_design/gemlog-cli/_view/list", config.CouchDB.Host, config.CouchDB.Port) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Add("authorization", genBasicAuthHeader(config.CouchDB.User, config.CouchDB.Password)) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("unexpected status code %d: %s", res.StatusCode, string(body)) + } + + // Parse CouchDB response + var couchResponse struct { + TotalRows int `json:"total_rows"` + Offset int `json:"offset"` + Rows []struct { + ID string `json:"id"` + Key string `json:"key"` + Value struct { + Title string `json:"title"` + Slug string `json:"slug"` + Date time.Time `json:"date"` + ID string `json:"id"` + } `json:"value"` + } `json:"rows"` + } + + if err := json.Unmarshal(body, &couchResponse); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Extract entries from response + entries := make([]GemlogListEntry, 0, len(couchResponse.Rows)) + for _, row := range couchResponse.Rows { + entry := GemlogListEntry{ + ID: row.ID, + Title: row.Value.Title, + Slug: row.Value.Slug, + Date: row.Value.Date, + } + entries = append(entries, entry) + } + + slog.Info("Found gemlogs", "count", len(entries)) + return entries, nil +} + func SaveGemlogEntry(config *Config, entry *GemlogEntry) error { url := fmt.Sprintf("%s:%d/gemlog/", config.CouchDB.Host, config.CouchDB.Port) @@ -23,9 +91,7 @@ func SaveGemlogEntry(config *Config, entry *GemlogEntry) error { return fmt.Errorf("failed to create request: %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("authorization", genBasicAuthHeader(config.CouchDB.User, config.CouchDB.Password)) req.Header.Add("content-type", "application/json") res, err := http.DefaultClient.Do(req) @@ -48,3 +114,22 @@ func SaveGemlogEntry(config *Config, entry *GemlogEntry) error { return nil } + +func CheckDBConnection(config *Config) error { + url := fmt.Sprintf("%s:%d/gemlog/", config.CouchDB.Host, config.CouchDB.Port) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Add("authorization", genBasicAuthHeader(config.CouchDB.User, config.CouchDB.Password)) + res, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode == 200 { + return nil + } + return fmt.Errorf("unexpected status code %d", res.StatusCode) +} diff --git a/gemlog/list.go b/gemlog/list.go new file mode 100644 index 0000000..17af8cf --- /dev/null +++ b/gemlog/list.go @@ -0,0 +1,18 @@ +package gemlog + +import ( + "log/slog" + + tea "github.com/charmbracelet/bubbletea" +) + +func LoadGemlogCMD(config *Config) tea.Cmd { + return func() tea.Msg { + logs, err := listGemLogs(config) + slog.Info("Loaded gemlogs", "count", len(logs)) + if err != nil { + return ErrorMsg{err} + } + return GemLogsLoaded{logs} + } +} diff --git a/main.go b/main.go index edf1c94..0f0c5f1 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "gemini_site/gemlog" + "log/slog" "os" tea "github.com/charmbracelet/bubbletea" @@ -23,10 +24,26 @@ func TODOCmd() tea.Msg { return gemlog.Notification("This action has not been implemented yet. Try another.") } +type Page string + +const ( + ActionList Page = "actionList" + EntryList Page = "entryList" + Entry Page = "entry" +) + +type entryListPageModel struct { + entries []gemlog.GemlogListEntry + action Action +} + type uiState struct { notification string cursor int errorTxt string + + page Page + entryListPage entryListPageModel } type context struct { @@ -41,7 +58,10 @@ type model struct { func initialModel(config *gemlog.Config) model { return model{ ui: uiState{ - // actions: []Action{Write, Read, Edit, Delete}, + page: ActionList, + entryListPage: entryListPageModel{ + entries: []gemlog.GemlogListEntry{}, + }, }, context: &context{ config: config, @@ -59,6 +79,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ui.errorTxt = fmt.Sprintf("%s\n\n", fmt.Errorf("%s", msg)) case gemlog.Notification: m.ui.notification = fmt.Sprintf("%s\n\n", string(msg)) + case gemlog.GemLogsLoaded: + m.ui.entryListPage.entries = msg.Logs + m.ui.cursor = 0 case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": @@ -77,7 +100,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case Write: return m, gemlog.WritePostCMD(m.context.config) case Read: - return m, TODOCmd + m.ui.page = EntryList + return m, gemlog.LoadGemlogCMD(m.context.config) case Edit: return m, TODOCmd case Delete: @@ -100,12 +124,26 @@ func (m model) View() string { s += "Welcome to gemlog cli!\n\nWhat post action would you like to take?\n\n" } - for i, action := range actions { - cursor := " " - if m.ui.cursor == i { - cursor = ">" + if m.ui.page == ActionList { + for i, action := range actions { + cursor := " " + if m.ui.cursor == i { + cursor = ">" + } + s += fmt.Sprintf("%s %s\n", cursor, action) + } + } + + if m.ui.page == EntryList { + slog.Info("rendering entry list", "count", len(m.ui.entryListPage.entries)) + for i, entry := range m.ui.entryListPage.entries { + slog.Info("entry", "value", entry) + cursor := " " + if m.ui.cursor == i { + cursor = ">" + } + s += fmt.Sprintf("%s %s : %s\n", cursor, entry.Date, entry.Slug) } - s += fmt.Sprintf("%s %s\n", cursor, action) } s += "\nPress q to quit.\n" @@ -114,12 +152,21 @@ func (m model) View() string { } func main() { + f, err := tea.LogToFile("debug.log", "debug") + if err != nil { + fmt.Println("fatal:", err) + os.Exit(1) + } + defer f.Close() + slog.Info("Starting gemlog cli") config, err := gemlog.LoadConfig() + // gemlog.CheckDBConnection(config) if err != nil { fmt.Printf("Error loading config: %v", err) os.Exit(1) } + // TODO: check if we can reach db before starting program p := tea.NewProgram(initialModel(config)) if _, err := p.Run(); err != nil { fmt.Printf("Alas, there's been an error: %v", err)