diff --git a/go.mod b/go.mod index 1ae5b58..5aa5133 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module miningtcup.me/notes-cli go 1.24.6 require ( + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/manifoldco/promptui v0.9.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect diff --git a/go.sum b/go.sum index 5eca24e..b90e993 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,13 @@ +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= @@ -10,6 +16,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/main.go b/main.go index e6ad3ea..dcb4317 100644 --- a/main.go +++ b/main.go @@ -2,23 +2,14 @@ package main import ( "bufio" - "bytes" - "crypto/aes" - "crypto/cipher" - "crypto/pbkdf2" - "crypto/sha256" "database/sql" - "encoding/base64" "encoding/json" "errors" "fmt" - "io" "io/fs" "log" - "net/http" "net/url" "os" - "os/exec" "os/signal" "slices" "strconv" @@ -29,6 +20,8 @@ import ( "golang.org/x/term" + "github.com/manifoldco/promptui" + _ "modernc.org/sqlite" ) @@ -51,15 +44,73 @@ type Note struct { Title string `json:"title"` } -func check(err error, format string, a ...any) bool { - if err != nil { - if writeErr := os.WriteFile("error.log", []byte(fmt.Sprintf(format+": %s", a, err)), 0600); writeErr != nil { - log.Fatalf("Error writing error '%s' to error.log: %s\n", err, writeErr) +func scanInt(promptFormat string, maxNum int64, minNum int64, promptA ...any) (choice int64, err error) { + validateInt := func(input string) error { + parsedInt, err := strconv.ParseInt(input, 10, 64) + if parsedInt > maxNum || parsedInt < minNum { + return fmt.Errorf("Out of bounds (must be between [inclusive] %d and %d)", minNum, maxNum) } - log.Fatalf("\033[31m"+format+"\033[0m: %s\n", a, err) - return false + return err } - return true + + prompt := promptui.Prompt{ + Label: fmt.Sprintf(promptFormat, promptA...), + Validate: validateInt, + } + + choiceString, err := prompt.Run() + if err != nil { + return + } + + choice, err = strconv.ParseInt(choiceString, 10, 64) + if err != nil { + return + } + + return +} + +func scanString(template string, promptFormat string, promptA ...any) (choice string, err error) { + prompt := promptui.Prompt{ + Label: fmt.Sprintf(promptFormat, promptA...), + Default: template, + } + + choice, err = prompt.Run() + if err != nil { + return + } + + return +} + +func scanConfirm(promptFormat string, promptA ...any) (choice string, err error) { + prompt := promptui.Prompt{ + Label: fmt.Sprintf(promptFormat, promptA...), + IsConfirm: true, + } + + choice, err = prompt.Run() + if err != nil { + return + } + + return +} + +func scanSelect(selection []string, template string, promptFormat string, promptA ...any) (choice int, err error) { + prompt := promptui.Select{ + Label: fmt.Sprintf(promptFormat, promptA...), + Items: selection, + } + + choice, _, err = prompt.Run() + if err != nil { + return + } + + return } func readPassword() (string, error) { @@ -87,22 +138,6 @@ func readPassword() (string, error) { return string(password), nil } -func pkcs7strip(data []byte, blockSize int) ([]byte, error) { - length := len(data) - if length == 0 { - return nil, errors.New("pkcs7: Data is empty") - } - if length%blockSize != 0 { - return nil, errors.New("pkcs7: Data is not block-aligned") - } - padLen := int(data[length-1]) - ref := bytes.Repeat([]byte{byte(padLen)}, padLen) - if padLen > blockSize || padLen == 0 || !bytes.HasSuffix(data, ref) { - return nil, errors.New("pkcs7: Invalid padding") - } - return data[:length-padLen], nil -} - func main() { scanner = *bufio.NewScanner(os.Stdin) home := os.Getenv("HOME") @@ -153,23 +188,25 @@ CREATE TABLE notes ( serverAddress, username, password = userData[0], userData[1], userData[2] var notes []Note - fzf := exec.Command("fzf", "--ansi") - pipe, err := fzf.StdinPipe() - check(err, "Error getting stdin pipe for fzf") - check(fzf.Start(), "Error starting fzf") + // fzf := exec.Command("fzf", "--ansi") + // pipe, err := fzf.StdinPipe() + // check(err, "Error getting stdin pipe for fzf") + // check(fzf.Start(), "Error starting fzf") var wg sync.WaitGroup - cacheJsonBody := load(db) + cacheJsonBody, err := load(db) + check(err, "Error loading data from cache") for i := range cacheJsonBody { - go list(cacheJsonBody[i][1], ¬es, &wg) + go list(cacheJsonBody[i], ¬es, &wg) wg.Add(1) } - fetchJsonBody := fetch() - - for i := range fetchJsonBody { - if !slices.Contains(cacheJsonBody, fetchJsonBody[i]) { - go list(fetchJsonBody[i][1], ¬es, &wg) - wg.Add(1) + fetchJsonBody, err := fetch() + if err == nil { + for i := range fetchJsonBody { + if !slices.Contains(cacheJsonBody, fetchJsonBody[i]) { + go list(fetchJsonBody[i], ¬es, &wg) + wg.Add(1) + } } } @@ -185,11 +222,73 @@ CREATE TABLE notes ( continue } date := time.Unix(timestamp/1000, 0).Format("Mon, Jan 01, 06") - fmt.Fprintf(pipe, "%s | \033[1m%s\033[0m | %s\n", date, notes[i].Title, strings.ReplaceAll(notes[i].Body, "\n", " / ")) + r, g, b, _ := decodeColorInt(notes[i].Color) + check(err, "Error decoding color int %d", notes[i].Color) + formattedNote := strings.TrimSpace( + fmt.Sprintf( + "[%02d] %s | \033[1;38;2;%d;%d;%dm%s\033[0m | %s\n", + i+1, + date, + r, + g, + b, + strings.TrimSpace(notes[i].Title), + strings.ReplaceAll( + notes[i].Body, + "\n", + "/", + ), + ), + ) + terminalWidth, _, err := term.GetSize(int(syscall.Stdin)) + check(err, "Error getting terminal size for stdin") + fmt.Printf("%.*s\n", terminalWidth, formattedNote) } - pipe.Close() + // pipe.Close() cache(db, fetchJsonBody) - fzf.Wait() + // fzf.Wait() + + fmt.Println() + if fetchJsonBody == nil { + fmt.Println("\033[1;31mNotes couldn't be loaded from the server.\033[0m") + fmt.Println("A connection to the server is required to create, edit, or delete notes.") + fmt.Println("\033[1mTroubleshooting steps:\033[0m") + fmt.Println(` * Check your internet or network connection. + * Make sure the server address is correct. + * Make sure the server is running the latest version of noteserver. + * Check the server's internet or network connection. + `) + return + } + chosenOperationIndex, err := scanSelect([]string{"\033[32mCreate\033[0m", "\033[31mDelete\033[0m", "\033[33mEdit\033[0m"}, "", "Select operation") + check(err, "Error scanning selection") + switch chosenOperationIndex { + case 0: + create(Note{}) + fmt.Println("Success!") + case 1: + chosenNoteIndex, err := scanInt( + "Select note to \033[31mdelete\033[37m by index (1-%d)", + int64(len(notes)), + 1, + len(notes), + ) + check(err, "Error scanning integer") + chosenNoteIndex -= 1 + scanConfirm("Really delete '%s'", notes[chosenNoteIndex].Title) + del(notes[chosenNoteIndex].ID, db) + case 2: + chosenNoteIndex, err := scanInt( + "Select note to \033[33medit\033[37m by index (1-%d)", + int64(len(notes)), + 1, + len(notes), + ) + check(err, "Error scanning integer") + chosenNoteIndex -= 1 + create(notes[chosenNoteIndex]) + del(notes[chosenNoteIndex].ID, db) + } default: login() } @@ -242,88 +341,13 @@ func login() { fmt.Println("\nAwesome! To get started, run 'notes add' or 'notes list'") } -func decrypt(data string, password string) string { - dataBytes, err := base64.StdEncoding.DecodeString(data) - check(err, "Error decoding base64 data") - - if len(dataBytes) < 32 { - return "" - } - - iv := dataBytes[0:16] - salt := dataBytes[16:32] - ciphertext := dataBytes[32:] - - if len(ciphertext)%aes.BlockSize != 0 { - fmt.Printf("Ciphertext length %d is not a multiple of block size %d\n", len(ciphertext), aes.BlockSize) - return "" - } - - key, err := pbkdf2.Key(sha256.New, password, salt, 65536, 32) - check(err, "Error deriving pbkdf2-sha256 key from password") - block, err := aes.NewCipher(key) - check(err, "Error creating AES key") - mode := cipher.NewCBCDecrypter(block, iv) - - plaintext := make([]byte, len(ciphertext)) - mode.CryptBlocks(plaintext, ciphertext) - - strippedPlaintext, err := pkcs7strip(plaintext, 16) +func check(err error, format string, a ...any) bool { if err != nil { - return "" - } - return string(strippedPlaintext) -} - -func fetch() [][2]string { - response, err := http.Get(fmt.Sprintf("https://%s/get?user=%s", serverAddress, url.QueryEscape(username))) - check(err, "Error getting notes from '%s' as '%s'", serverAddress, username) - defer response.Body.Close() - body, err := io.ReadAll(response.Body) - check(err, "Error reading response body while getting notes") - - var jsonBody [][2]string - err = json.Unmarshal(body, &jsonBody) - check(err, "Error unmarshalling response body") - - return jsonBody -} - -func load(db *sql.DB) [][2]string { - var rowCount int - err := db.QueryRow("SELECT COUNT(*) FROM notes").Scan(&rowCount) - if err != nil { - log.Fatal(err) - } - const limit = 100 - if rowCount > limit { // change this if there's ever an option to increase limit - rowCount = limit // change this if there's ever an option to increase limit - } - - rows, err := db.Query("SELECT * FROM notes LIMIT ?", rowCount) - check(err, "Error querying cache database", err) - defer rows.Close() - - notes := make([][2]string, rowCount) - - for i := 0; rows.Next(); i++ { - var currentRow [2]string - check(rows.Scan(¤tRow[0], ¤tRow[1]), "Error scanning cache database rows") - notes[i] = currentRow - } - - return notes -} - -func list(jsonBody string, notes *[]Note, wg *sync.WaitGroup) { - defer wg.Done() - var decryptedBody Note - err := json.Unmarshal([]byte(decrypt(jsonBody, password)), &decryptedBody) - if err != nil { - return - } - if len(decryptedBody.Title) > 0 && len(decryptedBody.Body) > 0 { - *notes = append(*notes, decryptedBody) // ew - return + if writeErr := os.WriteFile("error.log", fmt.Appendf([]byte{}, format+": %s", a, err), 0600); writeErr != nil { + log.Fatalf("\033[31mError writing error '%s' to error.log:\033[0m %s\n", err, writeErr) + } + log.Fatalf("\033[31m"+format+":\033[0m %s\n", a, err) + return false } + return true }