package main import ( "bufio" "database/sql" "encoding/json" "errors" "fmt" "io/fs" "log" "net/url" "os" "os/signal" "slices" "strconv" "strings" "sync" "syscall" "time" "golang.org/x/term" "github.com/manifoldco/promptui" _ "modernc.org/sqlite" ) var ( serverAddress string username string password string dataDir string dataPath string cacheDir string cachePath string scanner bufio.Scanner ) type Note struct { Body string `json:"body"` Color int64 `json:"color"` Date string `json:"date"` ID string `json:"id"` Title string `json:"title"` } 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) } return err } 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) { stdin := int(syscall.Stdin) oldState, err := term.GetState(stdin) if err != nil { return "", err } defer term.Restore(stdin, oldState) sigch := make(chan os.Signal, 1) signal.Notify(sigch, os.Interrupt) go func() { <-sigch term.Restore(stdin, oldState) os.Exit(1) }() password, err := term.ReadPassword(stdin) if err != nil { return "", err } return string(password), nil } func main() { scanner = *bufio.NewScanner(os.Stdin) home := os.Getenv("HOME") dataPath = os.Getenv("XDG_DATA_HOME") cachePath = os.Getenv("XDG_CACHE_HOME") if len(dataPath) < 1 { dataPath = home + "/.local/share" } if len(cachePath) < 1 { cachePath = home + "/.cache" } cacheDir = cachePath + "/notes" cachePath += "/notes/notes.db" dataDir = dataPath + "/notes" dataPath += "/notes/userinfo.json" err := os.Mkdir(cacheDir, 0700) if !errors.Is(err, os.ErrExist) { 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:] var command string if len(args) < 1 { command = "" } else { command = args[0] } switch command { case "ls": userDataBytes, err := os.ReadFile(dataPath) if errors.Is(err, fs.ErrNotExist) { login() } check(err, "Error reading user data at '%s'", dataPath) var userData []string err = json.Unmarshal(userDataBytes, &userData) check(err, "Error unmarshalling user data") 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 cacheJsonBody, err := load(db) check(err, "Error loading data from cache") for i := range cacheJsonBody { go list(cacheJsonBody[i], ¬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) } } } 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: 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() { fmt.Println("Welcome to Notes! To get started, choose your server and user.") fmt.Print("Server Address [notes.miningtcup.me]: ") _, err := fmt.Scanln(&serverAddress) if err != nil { serverAddress = "notes.miningtcup.me" } fmt.Print("Username: ") scanner.Scan() username = url.QueryEscape(scanner.Text()) fmt.Print("Password: ") passwordBytes, err := readPassword() check(err, "Error reading user input (password)") password = url.QueryEscape(string(passwordBytes)) jsonData, err := json.Marshal([]string{serverAddress, username, password}) check(err, "Error marshalling server address and credentials") err = os.Mkdir(dataDir, 0700) if !errors.Is(err, fs.ErrExist) { check(err, "Error creating data directory at '%s'", dataDir) } err = os.WriteFile(dataPath, jsonData, 0600) check(err, "Error writing to '%s'", dataPath) fmt.Println("\nAwesome! To get started, run 'notes add' or 'notes list'") } func check(err error, format string, a ...any) bool { if err != nil { 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 }