Compare commits

...

4 Commits

Author SHA1 Message Date
5d90305b95 a lot 2025-08-25 13:40:51 -07:00
17f34afb2e make stuff sorted and dates n stuff 2025-08-18 20:34:35 -07:00
56ca9d0cb8 prevent duplicates in cache and display 2025-08-18 14:56:01 -07:00
2aa18e77e3 caching (not ready) 2025-08-17 14:53:39 -07:00
3 changed files with 228 additions and 94 deletions

2
go.mod
View File

@@ -3,8 +3,10 @@ module miningtcup.me/notes-cli
go 1.24.6 go 1.24.6
require ( require (
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // 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/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect

7
go.sum
View File

@@ -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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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= 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= 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 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/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=

313
main.go
View File

@@ -1,30 +1,27 @@
package main package main
import ( import (
// "database/sql"
"bufio" "bufio"
"bytes" "database/sql"
"crypto/aes"
"crypto/cipher"
"crypto/pbkdf2"
"crypto/sha256"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"log" "log"
"net/http"
"net/url" "net/url"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"slices"
"strconv"
"strings" "strings"
"sync"
"syscall" "syscall"
"time"
"golang.org/x/term" "golang.org/x/term"
"github.com/manifoldco/promptui"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -34,6 +31,7 @@ var (
password string password string
dataDir string dataDir string
dataPath string dataPath string
cacheDir string
cachePath string cachePath string
scanner bufio.Scanner scanner bufio.Scanner
) )
@@ -46,12 +44,73 @@ type Note struct {
Title string `json:"title"` Title string `json:"title"`
} }
func check(err error, format string, a ...any) bool { func scanInt(promptFormat string, maxNum int64, minNum int64, promptA ...any) (choice int64, err error) {
if err != nil { validateInt := func(input string) error {
log.Fatalf(format, a, err) parsedInt, err := strconv.ParseInt(input, 10, 64)
return false if parsedInt > maxNum || parsedInt < minNum {
return fmt.Errorf("Out of bounds (must be between [inclusive] %d and %d)", minNum, maxNum)
}
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) { func readPassword() (string, error) {
@@ -79,22 +138,6 @@ func readPassword() (string, error) {
return string(password), nil 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() { func main() {
scanner = *bufio.NewScanner(os.Stdin) scanner = *bufio.NewScanner(os.Stdin)
home := os.Getenv("HOME") home := os.Getenv("HOME")
@@ -106,14 +149,25 @@ func main() {
if len(cachePath) < 1 { if len(cachePath) < 1 {
cachePath = home + "/.cache" cachePath = home + "/.cache"
} }
cacheDir = cachePath + "/notes"
cachePath += "/notes/notes.db" cachePath += "/notes/notes.db"
dataDir = dataPath + "/notes" dataDir = dataPath + "/notes"
dataPath += "/notes/userinfo.json" dataPath += "/notes/userinfo.json"
// db, err := sql.Open("sqlite", cachePath) err := os.Mkdir(cacheDir, 0700)
// if err != nil { if !errors.Is(err, os.ErrExist) {
// log.Fatal("Error opening sqlite database for notes cache at ", cachePath, ": ", err) check(err, "Error creating cache directory")
// } }
db, err := sql.Open("sqlite", cachePath)
check(err, "Error opening cache database at '%s'", cachePath)
check(db.Ping(), "Ping of cache database failed!")
db.Exec(`
CREATE TABLE notes (
id TEXT PRIMARY KEY,
data TEXT
);
`)
args := os.Args[1:] args := os.Args[1:]
var command string var command string
if len(args) < 1 { if len(args) < 1 {
@@ -122,49 +176,142 @@ func main() {
command = args[0] command = args[0]
} }
switch command { switch command {
case "l", "ls", "list", "search", "find": case "ls":
userDataBytes, err := os.ReadFile(dataPath) userDataBytes, err := os.ReadFile(dataPath)
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
login() login()
} }
check(err, "Error reading user data at '%s': %s", dataPath) check(err, "Error reading user data at '%s'", dataPath)
var userData []string var userData []string
err = json.Unmarshal(userDataBytes, &userData) err = json.Unmarshal(userDataBytes, &userData)
check(err, "Error unmarshalling user data: %s") check(err, "Error unmarshalling user data")
serverAddress, username, password = userData[0], userData[1], userData[2] 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")
var wg sync.WaitGroup
response, err := http.Get(fmt.Sprintf("https://%s/get?user=%s", serverAddress, url.QueryEscape(username))) cacheJsonBody, err := load(db)
check(err, "Error getting notes from '%s' as '%s': %s", serverAddress, username) check(err, "Error loading data from cache")
defer response.Body.Close() for i := range cacheJsonBody {
body, err := io.ReadAll(response.Body) go list(cacheJsonBody[i], &notes, &wg)
check(err, "Error reading response body while getting notes: %s") wg.Add(1)
var jsonBody [][2]string }
err = json.Unmarshal(body, &jsonBody) fetchJsonBody, err := fetch()
check(err, "Error unmarshalling response body: %s") if err == nil {
for i := range fetchJsonBody {
fzf := exec.Command("fzf") if !slices.Contains(cacheJsonBody, fetchJsonBody[i]) {
stdin, err := fzf.StdinPipe() go list(fetchJsonBody[i], &notes, &wg)
check(err, "Error executing fzf: %s") wg.Add(1)
check(fzf.Start(), "Error starting fzf: %s") }
decryptedBody := make([]Note, len(jsonBody))
for i := range len(jsonBody) {
err := json.Unmarshal([]byte(decrypt(jsonBody[i][1], password)), &decryptedBody[i])
if err != nil {
continue
}
if len(decryptedBody[i].Title) > 0 && len(decryptedBody[i].Body) > 0 {
fmt.Fprintf(stdin, "%s | %s\n", decryptedBody[i].Title, strings.ReplaceAll(decryptedBody[i].Body, "\n", " "))
} }
} }
fzf.Wait()
wg.Wait()
slices.SortFunc(notes, func(a, b Note) int {
return strings.Compare(b.Date, a.Date)
})
for i := range notes {
timestamp, err := strconv.ParseInt(notes[i].Date, 10, 64)
if err != nil {
fmt.Println("Error parsing Unix timestamp", err)
continue
}
date := time.Unix(timestamp/1000, 0).Format("Mon, Jan 01, 06")
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()
cache(db, fetchJsonBody)
// 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: default:
login() login()
} }
} }
func cache(db *sql.DB, jsonData [][2]string) {
for i := range jsonData {
var id string
err := db.QueryRow("SELECT id FROM notes WHERE id = ?", jsonData[i][0]).Scan(&id)
if err != sql.ErrNoRows {
check(err, "Error scanning cache database at %s for duplicates", cachePath)
continue
}
stmt, err := db.Prepare("INSERT INTO notes VALUES(?, ?)")
check(err, "Error preparing to insert note into cache database at %s", cachePath)
defer stmt.Close()
_, err = stmt.Exec(jsonData[i][0], jsonData[i][1])
check(err, "Error executing insert note into cache database at %s", cachePath)
}
}
func login() { func login() {
fmt.Println("Welcome to Notes! To get started, choose your server and user.") fmt.Println("Welcome to Notes! To get started, choose your server and user.")
fmt.Print("Server Address [notes.miningtcup.me]: ") fmt.Print("Server Address [notes.miningtcup.me]: ")
@@ -179,50 +326,28 @@ func login() {
fmt.Print("Password: ") fmt.Print("Password: ")
passwordBytes, err := readPassword() passwordBytes, err := readPassword()
check(err, "Error reading user input (password): %s") check(err, "Error reading user input (password)")
password = url.QueryEscape(string(passwordBytes)) password = url.QueryEscape(string(passwordBytes))
jsonData, err := json.Marshal([]string{serverAddress, username, password}) jsonData, err := json.Marshal([]string{serverAddress, username, password})
check(err, "Error marshalling server address and credentials: %s") check(err, "Error marshalling server address and credentials")
err = os.Mkdir(dataDir, 0700) err = os.Mkdir(dataDir, 0700)
if !errors.Is(err, fs.ErrExist) { if !errors.Is(err, fs.ErrExist) {
check(err, "Error creating data directory at '%s': %s", dataDir) check(err, "Error creating data directory at '%s'", dataDir)
} }
err = os.WriteFile(dataPath, jsonData, 0600) err = os.WriteFile(dataPath, jsonData, 0600)
check(err, "Error writing to '%s': ", dataPath) check(err, "Error writing to '%s'", dataPath)
fmt.Println("\nAwesome! To get started, run 'notes add' or 'notes list'") fmt.Println("\nAwesome! To get started, run 'notes add' or 'notes list'")
} }
func decrypt(data string, password string) string { func check(err error, format string, a ...any) bool {
dataBytes, err := base64.StdEncoding.DecodeString(data)
check(err, "Error decoding base64 data: %s")
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: %s")
block, err := aes.NewCipher(key)
check(err, "Error creating AES key: %s")
mode := cipher.NewCBCDecrypter(block, iv)
plaintext := make([]byte, len(ciphertext))
mode.CryptBlocks(plaintext, ciphertext)
strippedPlaintext, err := pkcs7strip(plaintext, 16)
if err != nil { if err != nil {
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 string(strippedPlaintext) return true
} }