journal/tui.go

596 lines
13 KiB
Go

package main
// Copyright (c) 2026, Julian Müller (ChaoticByte)
import (
"bufio"
"fmt"
"io"
"os"
"os/signal"
"slices"
"strconv"
"strings"
"time"
"github.com/awnumar/memguard"
"golang.org/x/term"
)
/*
This file includes stuff for the terminal user interface.
Entrypoint() -> mainloop() -> ...
*/
// terminal helpers
func Out(stuff ...any) {
// write stuff, without spaces between stuff1, stuff2, etc.
for _, s := range stuff {
fmt.Print(s)
}
}
func Nl() { Out("\n") }
func Nnl(n int) {
// write n lines
for range n {
Out("\n")
}
}
func Readline() (string, error) {
// read a single line from stdin
reader := bufio.NewReader(os.Stdin)
s, err := reader.ReadString('\n')
if err != nil {
return "", err
}
return s[:len(s)-1], err
}
func MultiChoiceOrCommand(choices [][2]string, commands []string, prompt string, helpLine string) int {
// Get a multiple-choice answer or a command from the user.
// returns the index, (-1 - index) for commands
// Handle SIGINT
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
go func() {
<-c
if j != nil { j.Close() }
Out(AS_RESET, AS_CUR_HOME)
memguard.SafeExit(0)
}()
defer Nl()
// output prompt, if any
if prompt != "" { Out(prompt); Nnl(2) }
// print choices, if any
if len(choices) > 0 {
Nl()
for _, c := range choices {
Out(" ", Am(AC_SET_BOLD), c[0], Am(AC_RESET_BOLD), " ", c[1]); Nl()
}
Nl()
}
// print help line
if helpLine != "" {
Nl()
Out(Am(AC_SET_UNDERLINE, AC_SET_DIM), "commands:", Am(AC_RESET_UNDERLINE, AC_RESET_DIM))
Nnl(2)
Out(helpLine)
Nnl(2)
}
Nl()
defer Out(Am(AC_COL_RESET_FG))
for {
// read lines until a valid choice or command is entered
Out(Am(AC_SET_BOLD, AC_COL_BRIGHT_YELLOW_FG), "> ", Am(AC_RESET_BOLD, AC_COL_RESET_FG))
a, err := Readline()
if err == io.EOF { Nl(); continue }
for i, c := range choices {
if c[0] == a {
return i
}
}
for i, c := range commands {
if c == a {
return -1 - i
}
}
}
}
func ReadPass() (*memguard.Enclave, error) {
// Read a password using term.ReadPassword()
// with additional fancy workarounds and shit
Nl()
Out("[ ] ")
Out(AS_SAVE_CUR_POS)
// Handle SIGINT (+ manual cleanup of term.Readpassword)
fd := int(os.Stdout.Fd())
s, err := term.GetState(fd); if err != nil { return nil, err }
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
go func() {
<-c
term.Restore(fd, s)
Nl()
memguard.SafeExit(0)
}()
defer term.Restore(fd, s) // doppelt hält besser
for {
Out(AS_RESTORE_CUR_POS, AS_ERASE_REST_OF_LINE)
pw, err := term.ReadPassword(fd); Nl()
if err != nil || len(pw) > 0 {
encl := memguard.NewEnclave(pw)
pw = nil // empty the plaintext password
Nl()
return encl, err
}
}
}
//
const (
UiListYears = iota
UiListMonths
UiListEntries
UiShowEntry
UiNewEntry
)
const EntryTimeFormat = "Monday, 02. January 2006 15:04:05 MST"
func mainloop(passwd *memguard.Enclave) int {
// erase screen and reset screen on exit.
Out(AS_ERASE_SCREEN, AS_CUR_HOME)
defer Out(AS_RESET, AS_CUR_HOME)
// Handle SIGINT
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
go func() {
<-c
j.Close()
Out(AS_RESET, AS_CUR_HOME)
memguard.SafeExit(0)
}()
// :)
// ui mode
lastMode := -1
mode := -1
// selections
selYear := -1
selMonth := ""
selEntry := uint64(1) // entry 0 is reserved, so use as default.
getHelp := func () string {
// returns the help line for the current mode
cmds := []string{}
addCmd := func(cmd string, expl string) {
cmds = append(
cmds,
cmd + " " +
Am(AC_SET_DIM) + expl + Am(AC_RESET_DIM))
}
if mode != UiListYears {
addCmd("Enter", "back")
}
if mode == UiShowEntry {
addCmd("delete", "Delete this entry")
}
if mode == UiShowEntry {
addCmd("a", "Previous")
addCmd("d", "Next")
}
if mode == UiListYears || mode == UiListMonths || mode == UiListEntries || mode == UiShowEntry {
addCmd("l", "Latest entry")
addCmd("n", "New Entry")
addCmd("q", "Exit the program")
}
return strings.Join(cmds, "\n")
}
writeJournalFile := func () int {
// returns a code to exit or -1 if no error
handleErr2 := func (err error, msg string) int {
Out(msg); Nl()
Out(err); Nnl(2)
Out(Am(AC_SET_DIM), "[Press Enter to exit program]", Am(AC_RESET_DIM))
Readline()
return 1
}
err := j.Write()
if err == FileModifiedExternally {
Out("The file was modified by another program since the last read/write.")
Nnl(2)
Out("[Press Enter when you are ready to overwrite the journal file]")
Readline(); Nl()
err = j.updateLastModifiedTime()
if err != nil {
return handleErr2(err, "Couldn't overwrite file. aborting.")
}
err = j.Write()
if err != nil {
return handleErr2(err, "Couldn't overwrite file. aborting.")
}
} else if err != nil {
return handleErr2(err, "Couldn't write journal file. aborting.")
}
return -1
}
for { // the actual main loop
// reset screen and put cursor in the top left
Out(AS_RESET, AS_CUR_HOME);
if mode == UiListYears || mode == UiListMonths || mode == UiListEntries {
// choices
choices := [][2]string{}
// used later
years := []int{}
months := []string{}
entries := []uint64{}
// collect list of choices based on filtered entries
es := j.GetEntries()
if len(es) > 0 {
slices.Sort(es)
i := 0
for _, ts := range es {
year := time.UnixMicro(int64(ts)).Local().Year()
month := time.UnixMicro(int64(ts)).Local().Month().String()
switch mode {
case UiListYears:
if !slices.Contains(years, year) {
years = append(years, year)
choices = append(choices, [2]string{strconv.Itoa(year), ""})
}
case UiListMonths:
if year == selYear {
if !slices.Contains(months, month) {
months = append(months, month)
choices = append(choices, [2]string{strconv.Itoa(i+1), month})
i += 1
}
}
case UiListEntries:
if year == selYear && month == selMonth {
if !slices.Contains(entries, ts) {
entries = append(entries, ts)
choices = append(
choices,
[2]string{
strconv.Itoa(i+1),
time.UnixMicro(int64(ts)).Format(EntryTimeFormat)},
)
i += 1
}
}
}
}
}
// commands
commands := []string{}
if mode == UiListYears {
commands = []string{"l", "n", "q"}
} else {
commands = []string{"", "l", "n", "q"}
}
// prompt
prompt := ""
if len(es) > 0 {
switch mode {
case UiListYears:
prompt = "Please select a " + Am(AC_SET_UNDERLINE) + "year"+ Am(AC_RESET_UNDERLINE)
case UiListMonths:
prompt = "Please select a " + Am(AC_SET_UNDERLINE) + "month"+ Am(AC_RESET_UNDERLINE)
case UiListEntries:
prompt = "Please select an " + Am(AC_SET_UNDERLINE) + "entry"+ Am(AC_RESET_UNDERLINE)
}
} else {
switch mode {
case UiListYears:
prompt = "Years (There are no entries yet)"
case UiListMonths:
prompt = "Months (There are no entries yet)"
case UiListEntries:
prompt = "Entries (There are no entries yet)"
}
}
sel := MultiChoiceOrCommand(
choices,
commands,
Am(AC_COL_BRIGHT_GREEN_FG) + prompt + Am(AC_COL_RESET_FG),
getHelp())
// prepare next iteration (or exit)
// based on user input
lastMode = mode
if mode == UiListYears {
if sel == -1 {
latest := j.GetLatestEntry()
if latest > 0 {
selEntry = latest
mode = UiShowEntry
}
} else if sel == -2 {
mode = UiNewEntry
} else if sel < -2 {
return 0 // exit
} else {
selYear = years[sel]
mode = UiListMonths
}
} else if mode == UiListMonths || mode == UiListEntries {
if sel == -1 {
if mode == UiListMonths {
mode = UiListYears
} else {
mode = UiListMonths
}
} else if sel == -2 {
latest := j.GetLatestEntry()
if latest > 0 {
selEntry = latest
mode = UiShowEntry
}
} else if sel == -3 {
mode = UiNewEntry
continue
} else if sel < -3 {
return 0 // exit
} else {
if mode == UiListMonths {
selMonth = months[sel]
mode = UiListEntries
} else {
selEntry = entries[sel]
mode = UiShowEntry
}
}
}
} else if mode == UiShowEntry {
// show a selected entry
e := j.GetEntry(selEntry)
if e != nil {
Out("[Decrypting ...] ")
txt, err := e.Decrypt(passwd)
Out("\r", AS_ERASE_LINE)
if err != nil {
Out("Entry could not be decrypted!"); Nl()
Out("Either the password is wrong or the entry is corrupted."); Nnl(2)
} else {
// Output Entry
Out(Am(AC_SET_UNDERLINE),
time.UnixMicro(int64(e.Timestamp)).Format(EntryTimeFormat),
Am(AC_RESET_UNDERLINE))
Nnl(4); Out(txt); Nnl(3)
txt = "" // don't keep the plaintext in memory
}
} else {
// this will likely never get called
// but catched a nil pointer deref
Out("Entry not found!"); Nnl(2)
Out(Am(AC_SET_DIM), "[Press Enter to go back]", Am(AC_RESET_DIM))
Readline()
mode = lastMode
continue
}
sel := MultiChoiceOrCommand(
[][2]string{},
[]string{"", "a", "d", "l", "q", "n", "delete"},
"", getHelp())
switch sel {
case -1:
mode = lastMode
case -2:
prev := j.GetPreviousEntry(selEntry)
if prev > 0 {
selEntry = prev
mode = UiShowEntry
}
case -3:
next := j.GetNextEntry(selEntry)
if next > 0 {
selEntry = next
mode = UiShowEntry
}
case -4:
latest := j.GetLatestEntry()
if latest > 0 {
selEntry = latest
mode = UiShowEntry
}
case -5:
return 0 // exit
case -6:
mode = UiNewEntry
case -7:
Nl(); Out(AS_ERASE_REST_OF_SCREEN)
answer := MultiChoiceOrCommand(
[][2]string{{"yes", ""}, {"no", ""}},
[]string{},
"Do you really want to delete this entry?", "")
if answer == 0 {
mode = lastMode
j.DeleteEntry(selEntry)
statusCode := writeJournalFile()
if statusCode >= 0 {
return statusCode
}
}
}
} else if mode == UiNewEntry {
// Create a new entry
handleErr := func(err error, out ...any) {
Out(out...); Nl()
Out(err.Error()); Nnl(2)
Out(Am(AC_SET_DIM), "[Press Enter to go back]", Am(AC_RESET_DIM))
Readline()
mode = lastMode
}
header := func () {
Out(Am(AC_COL_GREEN_FG),
"Write a new entry; ",
Am(AC_COL_RESET_FG, AC_SET_DIM),
"Save it by hitting ", Am(AC_RESET_DIM), "Ctrl+D",
Am(AC_SET_DIM), " in an empty line.\n",
"You can delete the previous line with ",
Am(AC_RESET_DIM), "dd", Am(AC_SET_DIM),
" and ", Am(AC_RESET_DIM), "Enter", Am(AC_RESET_DIM), ".")
Nnl(2)
}
header()
// read text from stdin (rune by rune)
lines := []string{}
for {
line, err := Readline()
if err == io.EOF {
break
} else if err != nil {
handleErr(err, "Couldn't read terminal input")
}
if line == "dd" {
ll := len(lines)
if ll < 1 {
lines = []string{}
} else {
lines = lines[:ll-1]
}
Out(AS_RESET, AS_CUR_HOME)
header()
for _, l := range lines {
Out(l); Nl()
}
} else {
lines = append(lines, line)
}
}
// Try to create new EncryptedEntry from the input text
e, err := NewEncryptedEntry(strings.Trim(strings.Join(lines, "\n"), " \n"), passwd)
if err != nil {
handleErr(err, "Error creating new entry")
continue
}
// empty input
lines = nil
err = j.AddEntry(e)
if err != nil {
handleErr(err, "Error adding new entry to journal")
continue
}
selEntry = e.Timestamp
// Update journal file
statusCode := writeJournalFile()
if statusCode >= 0 {
return statusCode
}
mode = UiShowEntry
} else {
mode = UiListYears
}
}
}
func PrintVersion() {
Out(Am(AC_SET_BOLD), "Journal " + Am(AC_RESET_BOLD, AC_COL_CYAN_FG) + Version + Am(AC_COL_RESET_FG)); Nnl(2)
}
func ShowUsageAndExit(a0 string, code int) {
PrintVersion()
a0Parts := strings.Split(a0, "/")
binName := a0Parts[len(a0Parts)-1]
Out("Usage: ",
binName,
" <path>\n\nPositional arguments\n\n\t<path> Path to the journal file\n\n")
os.Exit(code)
}
func Entrypoint() {
memguard.CatchInterrupt()
defer memguard.Purge()
// parse cli args
args := os.Args
if len(args) < 2 {
ShowUsageAndExit(args[0], 1)
}
a1 := args[1]
if a1 == "-h" || a1 == "--help" {
ShowUsageAndExit(args[0], 0)
}
// clear screen and go to top left corner
Out(AS_ERASE_SCREEN, AS_CUR_HOME);
PrintVersion()
Out("Please enter your encryption key."); Nl()
passwd, err := ReadPass()
if err != nil || passwd == nil {
Out("Couldn't get password from commandline safely."); Nl()
Out(err); Nl()
memguard.SafeExit(1)
}
Out("Opening journal file at ", Am(AC_SET_DIM), a1, Am(AC_RESET_DIM), " ...")
Nnl(2);
j, err = OpenJournalFile(a1, passwd)
if err != nil {
Out(Am(AC_COL_RED_FG), "Couldn't open journal file!", Am(AC_COL_RESET_FG))
Nl()
Out(err); Nnl(2)
Out("[Press Enter to exit]"); Readline()
memguard.SafeExit(1)
}
defer j.Close()
memguard.SafeExit(mainloop(passwd))
}