rn/rn.go

452 lines
10 KiB
Go

// 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 = `<html lang="en">
<head>
<meta charset="utf-8">
<title>Right now</title>
<meta content="width=device-width, initila-scale=1" name="viewport">
<meta content="What I am doing, thinking, searching for... right now" name="description">
<meta content="{{ .Author }}" name="author">
<style>
body {
margin: 30px 30px 30px 30px;
font-family: "Times New Roman";
color: rgba(0,0,0,.8);
background: #fff;
}
ul {
margin: 0;
padding: 0;
}
li {
list-style: none;
}
p {
margin: 0px;
display: inline;
}
time {
font-feature-settings: "lnum" on;
opacity: .5;
padding-right: 1em;
}
code {
font-size: .85em;
}
a {
text-decoration: underline;
text-underline-offset: .15em;
text-decoration-thickness: 1px;
}
</style>
</head>
<body>
<h1>Right now</h1>
<ul>
{{ range .Messages }}
<li><time>{{ .Date }}</time>{{ .Message }}</li>
{{ end }}
</ul>
</body>
</html>
`
// 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
}