504 lines
11 KiB
Go
504 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/signal"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
// ANSI ESCAPE CODES
|
|
|
|
const (
|
|
// suffixes
|
|
A_SFX_MODE = "m"
|
|
A_SFX_COLOR = A_SFX_MODE
|
|
|
|
// modes
|
|
A_SET_BOLD = "1"
|
|
A_RES_BOLD = "22"
|
|
A_SET_DIM = "2"
|
|
A_RES_DIM = "22"
|
|
A_SET_ITALIC = "3"
|
|
A_RES_ITALIC = "23"
|
|
A_SET_UNDERLINE = "4"
|
|
A_RES_UNDERLINE = "24"
|
|
A_SET_BLINK = "5"
|
|
A_RES_BLINK = "25"
|
|
A_SET_INVERTED = "7"
|
|
A_RES_INVERTED = "27"
|
|
A_SET_HIDDEN = "8"
|
|
A_RES_HIDDEN = "28"
|
|
A_SET_STRIKETHROUGH = "9"
|
|
A_RES_STRIKETHROUGH = "29"
|
|
|
|
// colors
|
|
A_COL_BLACK_FG = "30"
|
|
A_COL_BLACK_BG = "40"
|
|
A_COL_RED_FG = "31"
|
|
A_COL_RED_BG = "41"
|
|
A_COL_GREEN_FG = "32"
|
|
A_COL_GREEN_BG = "42"
|
|
A_COL_YELLOW_FG = "33"
|
|
A_COL_YELLOW_BG = "43"
|
|
A_COL_BLUE_FG = "34"
|
|
A_COL_BLUE_BG = "44"
|
|
A_COL_MAGENTA_FG = "35"
|
|
A_COL_MAGENTA_BG = "45"
|
|
A_COL_CYAN_FG = "36"
|
|
A_COL_CYAN_BG = "46"
|
|
A_COL_WHITE_FG = "37"
|
|
A_COL_WHITE_BG = "47"
|
|
|
|
// reset colors
|
|
A_COL_RES_FG = "39"
|
|
A_COL_RES_BG = "49"
|
|
|
|
// bright colors
|
|
A_COL_BRIGHT_BLACK_FG = "90"
|
|
A_COL_BRIGHT_BLACK_BG = "100"
|
|
A_COL_BRIGHT_RED_FG = "91"
|
|
A_COL_BRIGHT_RED_BG = "101"
|
|
A_COL_BRIGHT_GREEN_FG = "92"
|
|
A_COL_BRIGHT_GREEN_BG = "102"
|
|
A_COL_BRIGHT_YELLOW_FG = "93"
|
|
A_COL_BRIGHT_YELLOW_BG = "103"
|
|
A_COL_BRIGHT_BLUE_FG = "94"
|
|
A_COL_BRIGHT_BLUE_BG = "104"
|
|
A_COL_BRIGHT_MAGENTA_FG = "95"
|
|
A_COL_BRIGHT_MAGENTA_BG = "105"
|
|
A_COL_BRIGHT_CYAN_FG = "96"
|
|
A_COL_BRIGHT_CYAN_BG = "106"
|
|
A_COL_BRIGHT_WHITE_FG = "97"
|
|
A_COL_BRIGHT_WHITE_BG = "107"
|
|
)
|
|
|
|
func AE(suffix string, codes ...string) string {
|
|
seq := "\u001b["
|
|
seq += strings.Join(codes, ";")
|
|
seq += suffix
|
|
return seq
|
|
}
|
|
|
|
// Screen & erase
|
|
|
|
const A_RESET = "\u001b[3J\u001bc" // at least one of them should work
|
|
const A_ERASE_SCREEN = "\u001b[2J"
|
|
const A_ERASE_REST_OF_LINE = "\u001b[0K"
|
|
const A_ERASE_LINE = "\u001b[2K"
|
|
|
|
// const A_SAVE_SCREEN = "\u001b[?47h"
|
|
// const A_RESTORE_SCREEN = "\u001b[?47l"
|
|
|
|
// Cursor
|
|
|
|
const A_CUR_HOME = "\u001b[H"
|
|
const A_SAVE_CUR_POS = "\u001b7"
|
|
const A_RESTORE_CUR_POS = "\u001b8"
|
|
|
|
func ACurUp(lines int) string {
|
|
return fmt.Sprintf("\u001b[%vA", lines)
|
|
}
|
|
|
|
func ACurDown(lines int) string {
|
|
return fmt.Sprintf("\u001b[%vB", lines)
|
|
}
|
|
|
|
func ACurRight(cols int) string {
|
|
return fmt.Sprintf("\u001b[%vC", cols)
|
|
}
|
|
|
|
func ACurLeft(cols int) string {
|
|
return fmt.Sprintf("\u001b[%vD", cols)
|
|
}
|
|
|
|
|
|
// //
|
|
|
|
// some settings
|
|
|
|
const TextLeftMargin = 2
|
|
|
|
// terminal helpers
|
|
|
|
func Out(stuff ...any) {
|
|
for _, s := range stuff {
|
|
fmt.Print(s)
|
|
}
|
|
}
|
|
|
|
func Nl() {
|
|
Out("\n")
|
|
}
|
|
|
|
func Nnl(n int) {
|
|
for range n {
|
|
Out("\n")
|
|
}
|
|
}
|
|
|
|
func Readline() (string, error) {
|
|
reader := bufio.NewReader(os.Stdin)
|
|
s, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return s[:len(s)-1], err
|
|
}
|
|
|
|
func MultiPrompt(choices []string, hiddenChoices[]string) int {
|
|
// returns the index.
|
|
// hidden choices return (-1)-i
|
|
|
|
// i dont like this
|
|
c := make(chan os.Signal, 1)
|
|
signal.Notify(c, os.Interrupt)
|
|
go func() {
|
|
<-c
|
|
if j != nil { j.Close() }
|
|
Out(A_RESET, A_CUR_HOME)
|
|
os.Exit(0)
|
|
}()
|
|
|
|
Nl()
|
|
defer Nl()
|
|
for _, c := range choices { Out(" ", c); Nl() }; Nl()
|
|
Out("> ", A_SAVE_CUR_POS)
|
|
for {
|
|
Out(A_RESTORE_CUR_POS, A_ERASE_REST_OF_LINE)
|
|
a, _ := Readline()
|
|
for i, c := range choices {
|
|
if c == a {
|
|
return i
|
|
}
|
|
}
|
|
for i, c := range hiddenChoices {
|
|
if c == a {
|
|
return -1 - i
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func AdvancedMultiPrompt(choices [][2]string, hiddenChoices[]string) int {
|
|
// accepts a list of choices, each is a short keyword and the actual choice
|
|
// returns the index.
|
|
|
|
// i dont like this
|
|
c := make(chan os.Signal, 1)
|
|
signal.Notify(c, os.Interrupt)
|
|
go func() {
|
|
<-c
|
|
if j != nil { j.Close() }
|
|
Out(A_RESET, A_CUR_HOME)
|
|
os.Exit(0)
|
|
}()
|
|
|
|
//
|
|
Nl()
|
|
defer Nl()
|
|
for _, c := range choices {
|
|
fmt.Printf(" %s) %s\n", c[0], c[1])
|
|
}
|
|
Nl()
|
|
Out("> ", A_SAVE_CUR_POS)
|
|
for {
|
|
Out(A_RESTORE_CUR_POS, A_ERASE_REST_OF_LINE)
|
|
a, _ := Readline()
|
|
for i, c := range choices {
|
|
if c[0] == a {
|
|
return i
|
|
}
|
|
}
|
|
for i, c := range hiddenChoices {
|
|
if c == a {
|
|
return -1 - i
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func ReadPass() ([]byte, error) {
|
|
Nl()
|
|
Out("[ ] ")
|
|
Out(A_SAVE_CUR_POS)
|
|
|
|
// term.ReadPassword is very clever by not handling
|
|
// sigints correctly (at least in go version 1.25.5),
|
|
// so here we are with a another very pleasant (not) workarout
|
|
fd := int(os.Stdout.Fd())
|
|
s, err := term.GetState(fd); if err != nil { return []byte{}, err }
|
|
c := make(chan os.Signal, 1)
|
|
signal.Notify(c, os.Interrupt)
|
|
go func() {
|
|
<-c
|
|
term.Restore(fd, s)
|
|
Nl()
|
|
os.Exit(0)
|
|
}()
|
|
defer term.Restore(fd, s) // doppelt hält besser
|
|
|
|
// my god.
|
|
for {
|
|
Out(A_RESTORE_CUR_POS, A_ERASE_REST_OF_LINE)
|
|
pw, err := term.ReadPassword(fd); Nl()
|
|
if err != nil || len(pw) > 0 {
|
|
Nl()
|
|
return pw, err
|
|
}
|
|
}
|
|
}
|
|
|
|
//
|
|
|
|
const (
|
|
UiMainloopCtxListYears = iota
|
|
UiMainloopCtxListMonths
|
|
UiMainloopCtxListEntries
|
|
UiMainloopCtxShowEntry
|
|
UiMainloopCtxEditEntry
|
|
)
|
|
|
|
const EntryTimeFormat = "Monday, 02. January 2006 15:04:05 MST"
|
|
|
|
func mainloop(passwd []byte) int {
|
|
Out(A_ERASE_SCREEN, A_CUR_HOME)
|
|
defer Out(A_RESET, A_CUR_HOME)
|
|
|
|
// just for fun ;)
|
|
c := make(chan os.Signal, 1)
|
|
signal.Notify(c, os.Interrupt)
|
|
go func() {
|
|
<-c
|
|
j.Close()
|
|
Out(A_RESET, A_CUR_HOME)
|
|
os.Exit(0)
|
|
}()
|
|
// :)
|
|
|
|
// ok.
|
|
// let's start.
|
|
// pls.
|
|
lastMode := -1
|
|
mode := -1
|
|
selYear := -1
|
|
selMonth := ""
|
|
var selEntry uint64 = 0
|
|
//
|
|
for {
|
|
Out(A_RESET, A_CUR_HOME); Nl()
|
|
switch mode {
|
|
case UiMainloopCtxListYears:
|
|
years := []int{}
|
|
choices := []string{}
|
|
es := j.GetEntries()
|
|
slices.Sort(es)
|
|
for _, ts := range es {
|
|
year := time.UnixMicro(int64(ts)).Local().Year()
|
|
if !slices.Contains(years, year) {
|
|
years = append(years, year)
|
|
choices = append(choices, strconv.Itoa(year))
|
|
}
|
|
}
|
|
lastMode = mode
|
|
Out("Please select a year."); Nl()
|
|
sel := MultiPrompt(choices, []string{"new", "exit", "quit", "q"})
|
|
if sel == -1 {
|
|
mode = UiMainloopCtxEditEntry
|
|
continue
|
|
} else if sel < -1 {
|
|
return 0 // exit
|
|
}
|
|
selYear = years[sel]
|
|
mode = UiMainloopCtxListMonths
|
|
case UiMainloopCtxListMonths:
|
|
months := []string{}
|
|
choices := [][2]string{}
|
|
i := 0
|
|
es := j.GetEntries()
|
|
slices.Sort(es)
|
|
for _, ts := range es {
|
|
year := time.UnixMicro(int64(ts)).Local().Year()
|
|
if year == selYear {
|
|
month := time.UnixMicro(int64(ts)).Local().Month().String()
|
|
if !slices.Contains(months, month) {
|
|
months = append(months, month)
|
|
choices = append(choices, [2]string{strconv.Itoa(i+1), month})
|
|
i += 1
|
|
}
|
|
}
|
|
}
|
|
lastMode = mode
|
|
Out("Please select a month."); Nl()
|
|
sel := AdvancedMultiPrompt(choices, []string{"", "new", "exit", "quit", "q"})
|
|
if sel == -1 {
|
|
mode = UiMainloopCtxListYears
|
|
} else if sel == -2 {
|
|
mode = UiMainloopCtxEditEntry
|
|
} else if sel < -2 {
|
|
return 0 // exit
|
|
} else {
|
|
selMonth = months[sel]
|
|
mode = UiMainloopCtxListEntries
|
|
}
|
|
case UiMainloopCtxListEntries:
|
|
entries := []uint64{}
|
|
choices := [][2]string{}
|
|
es := j.GetEntries()
|
|
slices.Sort(es)
|
|
i := 0
|
|
for _, ts := range es {
|
|
year := time.UnixMicro(int64(ts)).Local().Year()
|
|
month := time.UnixMicro(int64(ts)).Local().Month().String()
|
|
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
|
|
}
|
|
}
|
|
}
|
|
Out("Please select an entry."); Nl()
|
|
sel := AdvancedMultiPrompt(choices, []string{"", "new", "exit", "quit", "q"})
|
|
lastMode = mode
|
|
if sel == -1 {
|
|
mode = UiMainloopCtxListMonths
|
|
} else if sel == -2 {
|
|
mode = UiMainloopCtxEditEntry
|
|
} else if sel < -2 {
|
|
return 0 // exit
|
|
} else {
|
|
selEntry = entries[sel]
|
|
mode = UiMainloopCtxShowEntry
|
|
}
|
|
case UiMainloopCtxShowEntry:
|
|
e := j.GetEntry(selEntry)
|
|
if e != nil {
|
|
Out("[Decrypting ...] ")
|
|
txt, err := e.Decrypt(passwd)
|
|
Out("\r", A_ERASE_LINE)
|
|
if err != nil {
|
|
Out("Entry could not be decrypted!")
|
|
Out("Either the password is wrong or the entry is corrupted.")
|
|
} else {
|
|
Out(time.UnixMicro(int64(e.Timestamp)).Format(EntryTimeFormat)); Nnl(2)
|
|
Out(txt); Nnl(2)
|
|
}
|
|
} else {
|
|
Out("Entry not found!"); Nnl(2)
|
|
Out("[Press ENTER to go back]"); Readline()
|
|
mode = lastMode
|
|
continue
|
|
}
|
|
Out("> "); Readline()
|
|
mode = lastMode
|
|
case UiMainloopCtxEditEntry:
|
|
handleErr := func(err error, out ...any) {
|
|
Out(out...); Nl()
|
|
Out(err.Error()); Nnl(2)
|
|
Out("[Press ENTER to go back]"); Readline()
|
|
mode = lastMode
|
|
}
|
|
Out("Write your new entry. Will listen until END."); Nnl(2)
|
|
lines := []string{}
|
|
for {
|
|
Out("| ")
|
|
line, err := Readline()
|
|
if err == io.EOF { break }
|
|
if line == "END" { break }
|
|
lines = append(lines, line)
|
|
}
|
|
e, err := NewEncryptedEntry(strings.Join(lines, "\n"), passwd)
|
|
if err != nil {
|
|
handleErr(err, "Error creating new entry")
|
|
continue
|
|
}
|
|
err = j.AddEntry(e)
|
|
if err != nil {
|
|
handleErr(err, "Error adding new entry to journal")
|
|
continue
|
|
}
|
|
selEntry = e.Timestamp
|
|
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()
|
|
err = j.updateLastModifiedTime()
|
|
if err != nil {
|
|
Out("Couldn't overwrite file. aborting."); Nl()
|
|
Out(err); Nnl(2)
|
|
Out("[Press Enter to exit program]"); Readline()
|
|
return 1
|
|
}
|
|
err = j.Write()
|
|
if err != nil {
|
|
Out("Couldn't overwrite file. aborting."); Nl()
|
|
Out(err); Nnl(2)
|
|
Out("[Press Enter to exit program]"); Readline()
|
|
return 1
|
|
}
|
|
} else if err != nil {
|
|
Out("Couldn't write journal file. aborting."); Nl()
|
|
Out(err); Nnl(2)
|
|
Out("[Press Enter to exit program]"); Readline()
|
|
}
|
|
mode = UiMainloopCtxShowEntry
|
|
default:
|
|
mode = UiMainloopCtxListYears
|
|
}
|
|
}
|
|
}
|
|
|
|
func ShowUsageAndExit(a0 string, code int) {
|
|
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 CliEntrypoint() {
|
|
args := os.Args
|
|
// parse cli 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 home
|
|
Out(A_ERASE_SCREEN, A_CUR_HOME); Nl()
|
|
|
|
// read password
|
|
Out("Please enter your encryption key."); Nl()
|
|
passwd, err := ReadPass()
|
|
if err != nil {
|
|
Out("Couldn't get password from commandline safely."); Nl()
|
|
Out(err); Nl()
|
|
os.Exit(1)
|
|
}
|
|
|
|
// try to open journal file
|
|
Out("Opening journal file at ", AE(A_SFX_MODE, A_SET_DIM), a1, AE(A_SFX_MODE, A_RES_DIM), " ...")
|
|
Nnl(2);
|
|
j, err = OpenJournalFile(a1)
|
|
if err != nil {
|
|
Out(AE(A_SFX_COLOR, A_COL_RED_FG), "Couldn't open journal file!", AE(A_SFX_COLOR, A_COL_RES_FG)); Nl()
|
|
Out(err); Nnl(2)
|
|
Out("[Press Enter to exit]"); Readline()
|
|
os.Exit(1)
|
|
}
|
|
defer j.Close()
|
|
|
|
// mainloop
|
|
os.Exit(mainloop(passwd))
|
|
|
|
}
|