diff --git a/README.md b/README.md index 836ba93..f55e7e8 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,23 @@ It’s a mirror. --- +## quick switcher + +Jump to a note by typing part of the title or a tag: + +```bash +jot switch +``` + +Then: + +1. Type a search query (partial words or tags like `#idea`). +2. Pick a number to open the note in your editor. + +`jot open` is an alias for `jot switch`. + +--- + ## patterns Eventually, curiosity wins. diff --git a/main.go b/main.go index e73e2cb..d84f001 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,10 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" + "sort" + "strconv" "strings" "time" ) @@ -38,7 +41,15 @@ func main() { return } - fmt.Fprintln(os.Stderr, "usage: jot [init|list|patterns]") + if len(args) >= 1 && (args[0] == "switch" || args[0] == "open") { + if err := jotSwitch(os.Stdin, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + + fmt.Fprintln(os.Stderr, "usage: jot [init|list|patterns|switch|open]") os.Exit(1) } @@ -179,3 +190,296 @@ func isTTY(w io.Writer) bool { } return (info.Mode() & os.ModeCharDevice) != 0 } + +type note struct { + Line int + Timestamp string + Text string + Tags []string +} + +type noteMatch struct { + Note note + Score int +} + +func jotSwitch(r io.Reader, w io.Writer) error { + journalPath, err := ensureJournal() + if err != nil { + return err + } + + notes, err := loadNotes(journalPath) + if err != nil { + return err + } + if len(notes) == 0 { + fmt.Fprintln(w, "no notes yet") + return nil + } + + reader := bufio.NewReader(r) + if _, err := fmt.Fprint(w, "search: "); err != nil { + return err + } + query, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return err + } + query = strings.TrimSpace(query) + + matches := searchNotes(notes, query) + if len(matches) == 0 { + fmt.Fprintln(w, "no matches") + return nil + } + + for i, match := range matches { + if _, err := fmt.Fprintf(w, "%d) %s\n", i+1, formatNote(match.Note)); err != nil { + return err + } + } + + if _, err := fmt.Fprint(w, "open: "); err != nil { + return err + } + selection, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return err + } + selection = strings.TrimSpace(selection) + if selection == "" || selection == "q" { + return nil + } + + idx, err := strconv.Atoi(selection) + if err != nil { + return fmt.Errorf("invalid selection: %s", selection) + } + if idx < 1 || idx > len(matches) { + return fmt.Errorf("selection out of range: %d", idx) + } + + editor := resolveEditor() + if editor == "" { + return errors.New("no editor configured") + } + + return openInEditor(editor, journalPath, matches[idx-1].Note.Line) +} + +func loadNotes(journalPath string) ([]note, error) { + file, err := os.Open(journalPath) + if err != nil { + return nil, err + } + defer file.Close() + + var notes []note + scanner := bufio.NewScanner(file) + lineNumber := 0 + for scanner.Scan() { + lineNumber++ + line := scanner.Text() + if strings.TrimSpace(line) == "" { + continue + } + parsed, ok := parseNote(line, lineNumber) + if ok { + notes = append(notes, parsed) + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + return notes, nil +} + +func parseNote(line string, lineNumber int) (note, bool) { + if strings.HasPrefix(line, "[") { + if end := strings.IndexByte(line, ']'); end > 0 { + timestamp := line[1:end] + text := strings.TrimSpace(line[end+1:]) + return note{ + Line: lineNumber, + Timestamp: timestamp, + Text: text, + Tags: extractTags(text), + }, true + } + } + + return note{}, false +} + +func extractTags(text string) []string { + fields := strings.Fields(text) + var tags []string + for _, field := range fields { + if strings.HasPrefix(field, "#") && len(field) > 1 { + tag := strings.TrimRight(field, ",.!?;:") + tags = append(tags, strings.TrimPrefix(tag, "#")) + } + } + return tags +} + +func searchNotes(notes []note, query string) []noteMatch { + query = strings.TrimSpace(query) + if query == "" { + matches := make([]noteMatch, 0, len(notes)) + for _, note := range notes { + matches = append(matches, noteMatch{Note: note, Score: 0}) + } + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].Note.Line > matches[j].Note.Line + }) + return matches + } + + tokens := strings.Fields(strings.ToLower(query)) + var matches []noteMatch + for _, note := range notes { + score, ok := matchScore(note, tokens) + if ok { + matches = append(matches, noteMatch{Note: note, Score: score}) + } + } + + sort.SliceStable(matches, func(i, j int) bool { + if matches[i].Score == matches[j].Score { + return matches[i].Note.Line > matches[j].Note.Line + } + return matches[i].Score > matches[j].Score + }) + return matches +} + +func matchScore(note note, tokens []string) (int, bool) { + text := strings.ToLower(note.Text) + tagMatches := make([]string, len(note.Tags)) + for i, tag := range note.Tags { + tagMatches[i] = strings.ToLower(tag) + } + + score := 0 + for _, token := range tokens { + if token == "" { + continue + } + if strings.HasPrefix(token, "#") { + token = strings.TrimPrefix(token, "#") + } + + best := 0 + if token != "" { + if tokenScore, ok := fuzzyScore(token, text); ok { + best = tokenScore + } + for _, tag := range tagMatches { + if tokenScore, ok := fuzzyScore(token, tag); ok && tokenScore > best { + best = tokenScore + 50 + } + } + } + + if best == 0 { + return 0, false + } + score += best + } + return score, true +} + +func fuzzyScore(query, target string) (int, bool) { + if query == "" { + return 0, true + } + if target == "" { + return 0, false + } + + if idx := strings.Index(target, query); idx >= 0 { + return 1000 - idx, true + } + + q := []rune(query) + t := []rune(target) + pos := 0 + consecutive := 0 + score := 0 + for _, qc := range q { + found := false + for pos < len(t) { + if t[pos] == qc { + found = true + break + } + pos++ + consecutive = 0 + } + if !found { + return 0, false + } + score += 10 + if consecutive > 0 { + score += 5 + } + if pos == 0 || t[pos-1] == ' ' || t[pos-1] == '#' { + score += 3 + } + consecutive++ + pos++ + } + return score, true +} + +func formatNote(note note) string { + if note.Timestamp == "" { + return note.Text + } + return fmt.Sprintf("[%s] %s", note.Timestamp, note.Text) +} + +func resolveEditor() string { + if editor := strings.TrimSpace(os.Getenv("JOT_EDITOR")); editor != "" { + return editor + } + if editor := strings.TrimSpace(os.Getenv("EDITOR")); editor != "" { + return editor + } + if editor := strings.TrimSpace(os.Getenv("VISUAL")); editor != "" { + return editor + } + return "vi" +} + +func openInEditor(editor string, path string, line int) error { + cmd, err := editorCommand(editor, path, line) + if err != nil { + return err + } + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func editorCommand(editor string, path string, line int) (*exec.Cmd, error) { + fields := strings.Fields(editor) + if len(fields) == 0 { + return nil, errors.New("editor command is empty") + } + cmdName := fields[0] + args := append([]string{}, fields[1:]...) + if line > 0 && supportsLineArg(cmdName) { + args = append(args, fmt.Sprintf("+%d", line)) + } + args = append(args, path) + return exec.Command(cmdName, args...), nil +} + +func supportsLineArg(editor string) bool { + base := filepath.Base(editor) + return base == "vim" || base == "nvim" || base == "vi" +} diff --git a/main_test.go b/main_test.go index 05850a6..5ed806f 100644 --- a/main_test.go +++ b/main_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "os" + "path/filepath" "runtime" "strings" "testing" @@ -123,3 +124,66 @@ func TestJotInitAppendsWithTimestamp(t *testing.T) { t.Fatalf("expected entry %q, got %q", expectedEntry, string(data)) } } + +func TestSearchNotesRanksPartialMatches(t *testing.T) { + notes := []note{ + {Line: 1, Text: "quiet note about resilience"}, + {Line: 2, Text: "loneliness isn't social, it's unseen"}, + {Line: 3, Text: "random thought"}, + } + + matches := searchNotes(notes, "lone") + if len(matches) == 0 { + t.Fatalf("expected matches, got none") + } + if matches[0].Note.Line != 2 { + t.Fatalf("expected best match line 2, got line %d", matches[0].Note.Line) + } +} + +func TestJotSwitchSearchAndOpen(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell script editor not supported on windows") + } + + home := withTempHome(t) + journalDir, journalPath := journalPaths(home) + if err := os.MkdirAll(journalDir, 0o700); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + content := strings.Join([]string{ + "[2024-01-01 10:00] first idea #alpha", + "[2024-01-02 09:00] lonely note #beta", + }, "\n") + "\n" + if err := os.WriteFile(journalPath, []byte(content), 0o600); err != nil { + t.Fatalf("write failed: %v", err) + } + + tempDir := t.TempDir() + editorPath := filepath.Join(tempDir, "vim") + outPath := filepath.Join(tempDir, "args.txt") + script := "#!/bin/sh\nprintf '%s' \"$@\" > " + outPath + "\n" + if err := os.WriteFile(editorPath, []byte(script), 0o700); err != nil { + t.Fatalf("write editor failed: %v", err) + } + t.Setenv("JOT_EDITOR", editorPath) + + input := "lon\n1\n" + var out bytes.Buffer + if err := jotSwitch(strings.NewReader(input), &out); err != nil { + t.Fatalf("jotSwitch returned error: %v", err) + } + + if !strings.Contains(out.String(), "1) [2024-01-02 09:00] lonely note #beta") { + t.Fatalf("expected search results to include lonely note, got %q", out.String()) + } + + args, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("read args failed: %v", err) + } + expectedArgs := "+2" + journalPath + if string(args) != expectedArgs { + t.Fatalf("expected editor args %q, got %q", expectedArgs, string(args)) + } +}