forked from tildetown/town
471 lines
12 KiB
Go
471 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.tilde.town/tildetown/town/invites"
|
|
"git.tilde.town/tildetown/town/models"
|
|
"git.tilde.town/tildetown/town/signup"
|
|
tuser "git.tilde.town/tildetown/town/user"
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
)
|
|
|
|
func getTitle() string {
|
|
titles := []string{
|
|
"yo bum rush the show",
|
|
"can i kick it?",
|
|
"super nintendo sega genesis",
|
|
"birthdays was the worst days",
|
|
"where were you when we were getting high?",
|
|
"it's real time, real time, time to get real",
|
|
}
|
|
return titles[rand.Intn(len(titles))]
|
|
}
|
|
|
|
type reviewer struct {
|
|
db *sql.DB
|
|
adminName string
|
|
}
|
|
|
|
func newReviewer(db *sql.DB, adminName string) *reviewer {
|
|
return &reviewer{db: db, adminName: adminName}
|
|
}
|
|
|
|
func (r *reviewer) Review(s *models.TownSignup, decision models.SignupDecision) error {
|
|
s.DecisionTime = time.Now()
|
|
s.Decision = decision
|
|
s.DecidedBy = r.adminName
|
|
return s.Review(r.db)
|
|
}
|
|
|
|
func (r *reviewer) AddNote(s *models.TownSignup, content string) error {
|
|
note := &models.SignupNote{
|
|
Author: r.adminName,
|
|
Content: content,
|
|
SignupID: s.ID,
|
|
}
|
|
|
|
return note.Insert(r.db)
|
|
}
|
|
|
|
func renderSignup(s models.TownSignup) string {
|
|
out := ""
|
|
|
|
pairs := [][]string{
|
|
{"submitted", s.Created.Format("2006-01-02 15:04")},
|
|
{"e-mail", s.Email},
|
|
{"how found / referral", s.How},
|
|
{"why like town / plans", s.Why},
|
|
{"links", s.Links},
|
|
}
|
|
|
|
for _, v := range pairs {
|
|
out += fmt.Sprintf("[-:-:b]%s[-:-:-]\n", v[0])
|
|
out += strings.TrimSpace(v[1])
|
|
out += "\n\n"
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func renderNotes(s models.TownSignup) string {
|
|
out := ""
|
|
for _, note := range s.Notes {
|
|
out += fmt.Sprintf(`%s said on %s:
|
|
%s`, note.Author, note.Created.Format("2006-01-02 15:04"), note.Content)
|
|
|
|
out += "\n\n"
|
|
|
|
}
|
|
return out
|
|
}
|
|
|
|
func searchSignups(signups []*models.TownSignup) (int, error) {
|
|
escapeNuls := func(str string) string {
|
|
return strings.ReplaceAll(str, "\000", " ")
|
|
}
|
|
|
|
buf := new(bytes.Buffer)
|
|
|
|
for ix, signup := range signups {
|
|
fmt.Fprintf(buf, "%d\t%s\000", ix, escapeNuls(signup.Email))
|
|
fmt.Fprintf(buf, "%d\t%s\000", ix, escapeNuls(signup.How))
|
|
fmt.Fprintf(buf, "%d\t%s\000", ix, escapeNuls(signup.Links))
|
|
fmt.Fprintf(buf, "%d\t%s\000", ix, escapeNuls(signup.Why))
|
|
}
|
|
|
|
cmd := exec.Command("fzf", "--read0", "--delimiter=\t", "--tac", "--with-nth=2..")
|
|
cmd.Stdin = buf
|
|
cmd.Stderr = os.Stderr
|
|
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return -1, err
|
|
}
|
|
|
|
if len(out) == 0 {
|
|
return -1, nil
|
|
}
|
|
|
|
s := strings.Split(string(out[:]), "\t")[0]
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return -1, err
|
|
}
|
|
|
|
return n, nil
|
|
}
|
|
|
|
func _main() error {
|
|
inviteDB, err := invites.ConnectDB()
|
|
if err != nil {
|
|
return fmt.Errorf("could not connect to invites database: %w", err)
|
|
}
|
|
|
|
signupDB, err := signup.ConnectDB()
|
|
if err != nil {
|
|
return fmt.Errorf("could not connect to signups database: %w", err)
|
|
}
|
|
|
|
u, err := user.Current()
|
|
if err != nil {
|
|
return fmt.Errorf("that's my purse. I don't know you! %w", err)
|
|
}
|
|
isAdmin, err := tuser.IsAdmin(u)
|
|
if err != nil {
|
|
return fmt.Errorf("that's my purse. I don't know you! %w", err)
|
|
}
|
|
|
|
if !isAdmin {
|
|
return errors.New("this command can only be run by a town admin")
|
|
}
|
|
|
|
r := newReviewer(signupDB, u.Username)
|
|
|
|
su := models.TownSignup{}
|
|
|
|
signups, err := su.All(signupDB)
|
|
if err != nil {
|
|
return fmt.Errorf("could not fetch signups: %w", err)
|
|
}
|
|
|
|
signupIx := 0
|
|
|
|
title := tview.NewTextView()
|
|
title.SetText(getTitle())
|
|
title.SetTextAlign(tview.AlignCenter)
|
|
title.SetTextColor(tcell.ColorPurple)
|
|
title.SetBackgroundColor(tcell.ColorBlack)
|
|
|
|
appView := tview.NewTextView()
|
|
appView.SetScrollable(true)
|
|
appView.SetDynamicColors(true)
|
|
|
|
legend := tview.NewTextView()
|
|
legend.SetText("s/S: next/prev r: random F: find A: approve R: reject N: notate Q: quit")
|
|
legend.SetTextColor(tcell.ColorPurple)
|
|
legend.SetTextAlign(tview.AlignCenter)
|
|
legend.SetBackgroundColor(tcell.ColorBlack)
|
|
|
|
count := tview.NewTextView()
|
|
count.SetDynamicColors(true)
|
|
updateCount := func() {
|
|
count.SetText(fmt.Sprintf("[-:-:b]%d of %d[-:-:-]", signupIx+1, len(signups)))
|
|
if len(signups) == 0 {
|
|
count.SetText("")
|
|
}
|
|
}
|
|
updateCount()
|
|
|
|
notesView := tview.NewTextView()
|
|
notesView.SetDynamicColors(true)
|
|
notesView.SetBorder(true).SetBorderColor(tcell.ColorPurple)
|
|
|
|
bottomFlex := tview.NewFlex()
|
|
bottomFlex.SetDirection(tview.FlexColumn)
|
|
bottomFlex.AddItem(count, 0, 1, false)
|
|
bottomFlex.AddItem(legend, 0, 10, false)
|
|
|
|
innerFlex := tview.NewFlex()
|
|
innerFlex.SetDirection(tview.FlexColumn)
|
|
innerFlex.AddItem(appView, 0, 2, true)
|
|
innerFlex.AddItem(notesView, 0, 1, true)
|
|
|
|
mainFlex := tview.NewFlex()
|
|
mainFlex.SetDirection(tview.FlexRow)
|
|
mainFlex.AddItem(title, 1, -1, false)
|
|
mainFlex.AddItem(innerFlex, 0, 1, false)
|
|
mainFlex.AddItem(bottomFlex, 1, -1, false)
|
|
// set scrollable
|
|
mainFlex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
appView.InputHandler()(event, func(p tview.Primitive) {})
|
|
return nil
|
|
})
|
|
|
|
pages := tview.NewPages()
|
|
|
|
errorModal := tview.NewModal()
|
|
errorModal.AddButtons([]string{"damn"})
|
|
errorModal.SetDoneFunc(func(ix int, _ string) {
|
|
pages.SwitchToPage("main")
|
|
})
|
|
|
|
render := func() {
|
|
if len(signups) == 0 {
|
|
appView.SetText("no signups")
|
|
return
|
|
}
|
|
currSignup := signups[signupIx]
|
|
err := currSignup.RefreshNotes(signupDB)
|
|
if err != nil {
|
|
errorModal.SetText(fmt.Sprintf("error! failed to add note: %s", err.Error()))
|
|
pages.SwitchToPage("error")
|
|
}
|
|
|
|
appView.SetText(renderSignup(*currSignup))
|
|
notesView.SetText(renderNotes(*currSignup))
|
|
}
|
|
|
|
render()
|
|
|
|
notate := tview.NewForm()
|
|
notate.AddTextArea("note", "", 80, 10, 1000, func(string) {})
|
|
notate.AddButton("submit", func() {
|
|
fi := notate.GetFormItemByLabel("note").(*tview.TextArea)
|
|
err = r.AddNote(signups[signupIx], fi.GetText())
|
|
if err != nil {
|
|
errorModal.SetText(fmt.Sprintf("error! failed to add note: %s", err.Error()))
|
|
pages.SwitchToPage("error")
|
|
return
|
|
}
|
|
|
|
render()
|
|
|
|
pages.SwitchToPage("main")
|
|
})
|
|
notate.AddButton("cancel", func() {
|
|
pages.SwitchToPage("main")
|
|
})
|
|
|
|
reviewModal := tview.NewFlex().SetDirection(tview.FlexRow)
|
|
|
|
providedEmailView := tview.NewTextView()
|
|
providedEmailView.SetTitle("provided email input")
|
|
|
|
reviewForm := tview.NewForm()
|
|
decisionFI := tview.NewDropDown().SetLabel("decision")
|
|
decisionFI.SetOptions([]string{"accepted", "rejected"}, func(_ string, _ int) {})
|
|
|
|
cleanEmailInput := tview.NewInputField()
|
|
cleanEmailInput.SetLabel("clean email ")
|
|
cleanEmailInput.SetAcceptanceFunc(func(tx string, _ rune) bool { return len(tx) > 0 })
|
|
|
|
reviewForm.AddFormItem(decisionFI)
|
|
reviewForm.AddFormItem(cleanEmailInput)
|
|
reviewForm.AddButton("submit", func() {
|
|
currSignup := signups[signupIx]
|
|
cleanEmail := cleanEmailInput.GetText()
|
|
currSignup.CleanEmail = cleanEmail
|
|
|
|
decision := models.SignupRejected
|
|
_, d := decisionFI.GetCurrentOption()
|
|
if d == "accepted" {
|
|
decision = models.SignupAccepted
|
|
}
|
|
|
|
err := r.Review(currSignup, decision)
|
|
if err != nil {
|
|
errorModal.SetText(fmt.Sprintf("error! failed to submit review: %s", err.Error()))
|
|
pages.SwitchToPage("error")
|
|
return
|
|
}
|
|
|
|
newSignups := []*models.TownSignup{}
|
|
for ix, s := range signups {
|
|
if ix != signupIx {
|
|
newSignups = append(newSignups, s)
|
|
}
|
|
}
|
|
signups = newSignups
|
|
if len(signups) > 0 {
|
|
if signupIx >= len(signups) {
|
|
signupIx = 0
|
|
}
|
|
}
|
|
updateCount()
|
|
render()
|
|
if decision == models.SignupAccepted {
|
|
invite := &invites.Invite{
|
|
Email: currSignup.CleanEmail,
|
|
}
|
|
|
|
if err = invite.Insert(inviteDB); err != nil {
|
|
errorModal.SetText(fmt.Sprintf("error! failed to create invite: %s", err.Error()))
|
|
pages.SwitchToPage("error")
|
|
}
|
|
|
|
if err = sendInviteEmail(*invite); err != nil {
|
|
errorModal.SetText(fmt.Sprintf("error! failed to send welcome email: %s", err.Error()))
|
|
pages.SwitchToPage("error")
|
|
}
|
|
}
|
|
pages.SwitchToPage("main")
|
|
})
|
|
reviewForm.AddButton("cancel", func() {
|
|
pages.SwitchToPage("main")
|
|
})
|
|
|
|
reviewModal.AddItem(tview.NewTextView().SetText("provided email input"), 1, 1, false)
|
|
reviewModal.AddItem(providedEmailView, 0, 1, false)
|
|
reviewModal.AddItem(reviewForm, 0, 1, true)
|
|
|
|
pages.AddPage("main", mainFlex, true, true)
|
|
pages.AddPage("error", errorModal, false, false)
|
|
pages.AddPage("notate", notate, true, false)
|
|
pages.AddPage("review", reviewModal, true, false)
|
|
|
|
app := tview.NewApplication()
|
|
app.SetRoot(pages, true)
|
|
|
|
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
currPage, _ := pages.GetFrontPage()
|
|
if currPage == "notate" || currPage == "review" {
|
|
return event
|
|
}
|
|
switch event.Rune() {
|
|
case 's':
|
|
signupIx++
|
|
if signupIx == len(signups) {
|
|
signupIx = 0
|
|
}
|
|
updateCount()
|
|
render()
|
|
return nil
|
|
case 'S':
|
|
signupIx--
|
|
if signupIx < 0 {
|
|
signupIx = len(signups) - 1
|
|
}
|
|
updateCount()
|
|
render()
|
|
return nil
|
|
case 'r':
|
|
if len(signups) > 0 {
|
|
signupIx = rand.Intn(len(signups))
|
|
updateCount()
|
|
render()
|
|
}
|
|
// TODO: there's a bunch of messy state management.
|
|
// should we generate this pane functionally?
|
|
case 'A':
|
|
if len(signups) == 0 {
|
|
return nil
|
|
}
|
|
emailVal := signups[signupIx].Email
|
|
providedEmailView.SetText(emailVal)
|
|
cleanEmailInput.SetLabel("clean email ")
|
|
cleanEmailInput.SetText("")
|
|
/*
|
|
TODO the placeholder doesn't appear to become the default text which is
|
|
what I wanted it to do. Just taking this out so the blank box beckons
|
|
input. Also, it seems like the AcceptanceFunc didn't work since a blank
|
|
value got through.
|
|
cleanEmailInput.SetPlaceholder(
|
|
strings.TrimSpace(strings.ReplaceAll(emailVal, "\n", " ")))
|
|
*/
|
|
cleanEmailInput.SetChangedFunc(func(text string) {
|
|
if strings.Contains(emailVal, text) {
|
|
cleanEmailInput.SetLabel("clean email ")
|
|
} else {
|
|
cleanEmailInput.SetLabel("[red]clean email :(")
|
|
}
|
|
})
|
|
decisionFI.SetCurrentOption(0)
|
|
pages.SwitchToPage("review")
|
|
app.SetFocus(cleanEmailInput)
|
|
return nil
|
|
case 'R':
|
|
if len(signups) == 0 {
|
|
return nil
|
|
}
|
|
emailVal := signups[signupIx].Email
|
|
providedEmailView.SetText(emailVal)
|
|
cleanEmailInput.SetLabel("clean email ")
|
|
cleanEmailInput.SetText("")
|
|
/*
|
|
TODO the placeholder doesn't appear to become the default text which is
|
|
what I wanted it to do. Just taking this out so the blank box beckons
|
|
input. Also, it seems like the AcceptanceFunc didn't work since a blank
|
|
value got through.
|
|
cleanEmailInput.SetPlaceholder(
|
|
strings.TrimSpace(strings.ReplaceAll(emailVal, "\n", " ")))
|
|
*/
|
|
cleanEmailInput.SetChangedFunc(func(text string) {
|
|
if strings.Contains(emailVal, text) {
|
|
cleanEmailInput.SetLabel("clean email ")
|
|
} else {
|
|
cleanEmailInput.SetLabel("[red]clean email :(")
|
|
}
|
|
})
|
|
decisionFI.SetCurrentOption(1)
|
|
pages.SwitchToPage("review")
|
|
app.SetFocus(cleanEmailInput)
|
|
return nil
|
|
case 'N':
|
|
if len(signups) == 0 {
|
|
return nil
|
|
}
|
|
pages.SwitchToPage("notate")
|
|
return nil
|
|
case 'F':
|
|
app.Suspend(func() {
|
|
ix, err := searchSignups(signups)
|
|
if err != nil {
|
|
if exiterr, ok := err.(*exec.ExitError); ok {
|
|
// no match or interrupt. who cares
|
|
switch exiterr.ExitCode() {
|
|
case 1:
|
|
case 130:
|
|
return
|
|
}
|
|
}
|
|
|
|
errorModal.SetText(fmt.Sprintf("error! failed to search: %s", err.Error()))
|
|
pages.SwitchToPage("error")
|
|
} else if ix >= 0 {
|
|
signupIx = ix
|
|
}
|
|
})
|
|
|
|
updateCount()
|
|
render()
|
|
return nil
|
|
case 'Q':
|
|
app.Stop()
|
|
}
|
|
|
|
return event
|
|
})
|
|
|
|
return app.Run()
|
|
}
|
|
|
|
func main() {
|
|
err := _main()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|