452 lines
10 KiB
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
|
|
}
|