// rn is a Go implementation of Right now. package main import ( "bytes" "errors" "flag" "fmt" "io" "log" "math/rand" "net" "os" "os/user" "path/filepath" "strings" "text/template" "time" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/object" "github.com/gomarkdown/markdown" "github.com/kevinburke/ssh_config" "github.com/povsister/scp" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" ) // motivatingExamples are various placeholder texts for the text input. var motivatingExamples = []string{ "Drinking a tea...", "Reading a nice book...", "Hacking on PrePostPrint...", "Eating good food...", "Hanging out with friends...", "Dancing to the revolution...", } // errMsg represents an error message type. type errMsg error // publishMsg signals a publish command. type publishMsg struct{ html *bytes.Buffer } // remotePushMsg signals a Git push to a remote. type remotePushMsg struct{} // model is the main data structure for the program. type model struct { input textinput.Model // textinput for message username string // system username hostname string // system hostname homeDir string // user home directory gitDir string // rn git directory message string // input of message gitMessages []gitMessage // all messages from the git repository err error // program error publish string // where to publish to publishing bool // whether or not the program is running scp } // htmlTemplate is the HTML template which will be generated from the messages. var htmlTemplate = ` Right now

Right now

` // gitMessage is the message parsed from the git log type gitMessage struct { Date string // date when message posted Message string // content of the message } // gitMessages represents all the retrieved git messages. type gitMessages []gitMessage const help = `rn [options] A Go implementation of "Right now". Options: -h output help -p publish to web Example: rn -p varia.zone:public_html/rn ` var ( helpFlag bool // show help output publishFlag string // publish to web ) // handleCliFlags parses CLI flags. func handleCliFlags() { flag.BoolVar(&helpFlag, "h", false, "output help") flag.StringVar(&publishFlag, "p", "", "publish to web") flag.Parse() } // main is the command-line entrypoint. func main() { handleCliFlags() if helpFlag { fmt.Print(help) os.Exit(0) } usr, err := user.Current() if err != nil { log.Fatal(err) } hostname, err := os.Hostname() if err != nil { log.Fatal(err) } prog := tea.NewProgram(initialModel(usr, hostname, publishFlag)) if _, err := prog.Run(); err != nil { log.Fatal(err) } } // initialModel initialises the model. func initialModel(usr *user.User, hostname, publish string) model { input := textinput.New() randInt := rand.Intn(len(motivatingExamples)) input.Placeholder = motivatingExamples[randInt] input.Focus() return model{ input: input, homeDir: usr.HomeDir, gitDir: filepath.Join(usr.HomeDir, ".rn"), username: usr.Username, hostname: hostname, publish: publish, err: nil, } } // Init initialises the view. func (m model) Init() tea.Cmd { return textinput.Blink } // Update mutates the model and updates the view. func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c": return m, tea.Quit case "enter": m.message = m.input.Value() if err := saveMessage(m); err != nil { m.err = err return m, nil } if m.publish != "" { m.publishing = true messages, err := getMessages(m) if err != nil { return m, nil } m.gitMessages = messages html, err := generateHtml(m) if err != nil { m.err = err return m, nil } return m, func() tea.Msg { return publishMsg{html: html} } } return m, func() tea.Msg { return remotePushMsg{} } } case errMsg: m.err = msg return m, nil case publishMsg: reader := bytes.NewReader(msg.html.Bytes()) if err := scpPublish(m, reader); err != nil { m.err = err return m, nil } return m, func() tea.Msg { return remotePushMsg{} } case remotePushMsg: if err := remotePush(m); err != nil { m.err = err return m, nil } return m, tea.Quit default: cmd = m.input.Focus() cmds = append(cmds, cmd) } m.input, cmd = m.input.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } // View renders the view. func (m model) View() string { body := strings.Builder{} if m.err != nil { body.WriteString(fmt.Sprintf("error: %s\n", m.err.Error())) body.WriteString("ctrl+c: quit") return body.String() } if m.publishing { body.WriteString("Publishing changes...") return body.String() } body.WriteString("What are you up to right now?\n") body.WriteString(fmt.Sprintf("%s\n", m.input.View())) body.WriteString("enter: submit | ctrl+c: quit") return body.String() } // saveMessage saves a new git commit message. It will create a repository if // it does not exist. If a remote is configured, it will automatically // pull/push changes. func saveMessage(model model) error { if _, err := git.PlainInit(model.gitDir, false); err != nil { if !errors.Is(git.ErrRepositoryAlreadyExists, err) { return fmt.Errorf("saveMessage: unable to initialise repo: %s", err) } } repo, err := git.PlainOpen(model.gitDir) if err != nil { return fmt.Errorf("saveMessage: unable to open repo: %s", err) } worktree, err := repo.Worktree() if err != nil { return fmt.Errorf("saveMessage: unable to open worktree: %s", err) } if _, err := worktree.Commit(model.message, &git.CommitOptions{ AllowEmptyCommits: true, Author: &object.Signature{ Name: model.username, Email: fmt.Sprintf("%s@%s", model.username, model.hostname), When: time.Now(), }, }); err != nil { return fmt.Errorf("saveMessage: unable to create commit: %s", err) } return nil } func getMessages(model model) (gitMessages, error) { msgs := gitMessages{} repo, err := git.PlainOpen(model.gitDir) if err != nil { return msgs, fmt.Errorf("getMessages: unable to open repo: %s", err) } log, err := repo.Log(&git.LogOptions{}) if err != nil { return msgs, fmt.Errorf("getMessages: unable to read git log: %s", err) } if err = log.ForEach(func(cmt *object.Commit) error { msgs = append(msgs, gitMessage{ Date: cmt.Author.When.Format(time.DateOnly), Message: cmt.Message, }) return nil }); err != nil { return msgs, fmt.Errorf("getMessages: unable to iterate over commits: %s", err) } return msgs, nil } // generateHtml generates a Html page from the git log. func generateHtml(model model) (*bytes.Buffer, error) { tmpl, err := template.New("index").Parse(htmlTemplate) if err != nil { return nil, fmt.Errorf("generateHtml: unable to parse template: %s", err) } var htmlMessages []gitMessage for _, gMsg := range model.gitMessages { html := markdown.ToHTML([]byte(gMsg.Message), nil, nil) htmlMessages = append(htmlMessages, gitMessage{ Date: gMsg.Date, Message: strings.TrimSuffix(string(html), "\n"), }) } tmplData := struct { Author string Messages []gitMessage }{ Author: model.username, Messages: htmlMessages, } html := &bytes.Buffer{} if err := tmpl.Execute(html, tmplData); err != nil { return nil, fmt.Errorf("generateHtml: unable to execute template: %s", err) } return html, nil } // scpPublish initates a scp-like publishing interface. We do our best here to // simply work with existing local work station SSH client configurations. It // is mostly up to folks to configure their own shit. We don't do anything // fancy here. func scpPublish(model model, html io.Reader) error { split := strings.Split(model.publish, ":") server, remotePath := split[0], split[1] sshUser := ssh_config.Get(server, "User") if sshUser == "" { sshUser = model.username } sshPort := ssh_config.Get(server, "Port") sshConf := &ssh.ClientConfig{ User: sshUser, HostKeyCallback: ssh.InsecureIgnoreHostKey(), // moving fast, woops Timeout: 10 * time.Second, } identityFile := ssh_config.Get(server, "IdentityFile") if identityFile != "" && identityFile != "~/.ssh/identity" { sshAgent, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")) if err != nil { return fmt.Errorf("scpPublish: unable to connect to local ssh-agent, is it running?") } agentCl := agent.NewClient(sshAgent) authMethod := ssh.PublicKeysCallback(agentCl.Signers) sshConf.Auth = []ssh.AuthMethod{authMethod} } serverAndPort := fmt.Sprintf("%s:%s", server, sshPort) scpClient, err := scp.NewClient(serverAndPort, sshConf, &scp.ClientOption{}) if err != nil { return fmt.Errorf("scpPublish: unable to make SSH connection to %s, have you configured your SSH client?", server) } defer scpClient.Close() remotePathWithIndex := fmt.Sprintf("%s/index.html", remotePath) if err := scpClient.CopyToRemote(html, remotePathWithIndex, &scp.FileTransferOption{}); err != nil { return fmt.Errorf("scpPublish: woops, publishing failed: %s", err.Error()) } return nil } // remotePush runs a Git push to a remote. func remotePush(model model) error { repo, err := git.PlainOpen(model.gitDir) if err != nil { return fmt.Errorf("remotePush: unable to open repo: %s", err) } if err := repo.Push(&git.PushOptions{}); err != nil { if !errors.Is(git.ErrRemoteNotFound, err) { return fmt.Errorf("remotePush: unable to push changes: %s", err) } } return nil }