From 42472a852673ad14e36d9c8760288a8893accde4 Mon Sep 17 00:00:00 2001 From: mamba Date: Wed, 28 Jan 2026 13:41:03 +0000 Subject: [PATCH] Add search filters for tags dates and projects --- README.md | 17 +++++ main.go | 209 ++++++++++++++++++++++++++++++++++++++++++++++++++- main_test.go | 83 ++++++++++++++++++++ 3 files changed, 308 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 836ba93..86bd74f 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,23 @@ It’s a mirror. --- +## search (filters) + +Use `jot search` to find entries, and optionally filter by tag, date, and project/repo context. + +```bash +jot search "incident" +jot search "incident" --tag prod --since 2024-01-01 --project myrepo +``` + +Filters are combinable: + +* **Tags**: add `#tag` to your entry, then filter with `--tag tag` (repeatable). +* **Date**: use `--since YYYY-MM-DD` and/or `--until YYYY-MM-DD` to set a range. +* **Project**: add `project:myrepo` or `repo:myrepo` to your entry, then filter with `--project myrepo`. + +--- + ## patterns Eventually, curiosity wins. diff --git a/main.go b/main.go index e73e2cb..89f778b 100644 --- a/main.go +++ b/main.go @@ -3,10 +3,12 @@ package main import ( "bufio" "errors" + "flag" "fmt" "io" "os" "path/filepath" + "regexp" "strings" "time" ) @@ -38,7 +40,16 @@ func main() { return } - fmt.Fprintln(os.Stderr, "usage: jot [init|list|patterns]") + if len(args) > 0 && args[0] == "search" { + if err := runJotSearch(args[1:], os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + printSearchUsage(os.Stderr) + os.Exit(1) + } + return + } + + printUsage(os.Stderr) os.Exit(1) } @@ -138,6 +149,194 @@ func jotList(w io.Writer) error { return nil } +type multiString []string + +func (m *multiString) String() string { + return strings.Join(*m, ",") +} + +func (m *multiString) Set(value string) error { + if value == "" { + return errors.New("tag cannot be empty") + } + *m = append(*m, value) + return nil +} + +type searchOptions struct { + tags []string + since *time.Time + until *time.Time + project string +} + +func runJotSearch(args []string, w io.Writer) error { + var tags multiString + var sinceInput string + var untilInput string + var project string + + flags := flag.NewFlagSet("search", flag.ContinueOnError) + flags.SetOutput(io.Discard) + flags.Var(&tags, "tag", "filter by tag (repeatable)") + flags.StringVar(&sinceInput, "since", "", "filter entries on or after YYYY-MM-DD") + flags.StringVar(&untilInput, "until", "", "filter entries on or before YYYY-MM-DD") + flags.StringVar(&project, "project", "", "filter entries by project/repo context") + + if err := flags.Parse(args); err != nil { + return err + } + + query := strings.TrimSpace(strings.Join(flags.Args(), " ")) + if query == "" { + return errors.New("search query required") + } + + var opts searchOptions + opts.tags = tags + opts.project = strings.TrimSpace(project) + + if sinceInput != "" { + since, err := parseDateInput(sinceInput) + if err != nil { + return fmt.Errorf("invalid --since date: %w", err) + } + opts.since = &since + } + + if untilInput != "" { + until, err := parseDateInput(untilInput) + if err != nil { + return fmt.Errorf("invalid --until date: %w", err) + } + opts.until = &until + } + + return jotSearch(w, query, opts) +} + +func parseDateInput(input string) (time.Time, error) { + parsed, err := time.Parse("2006-01-02", input) + if err != nil { + return time.Time{}, err + } + return dateOnly(parsed), nil +} + +type journalEntry struct { + timestamp time.Time + text string +} + +func jotSearch(w io.Writer, query string, opts searchOptions) error { + journalPath, err := ensureJournal() + if err != nil { + return err + } + + file, err := os.Open(journalPath) + if err != nil { + return err + } + defer file.Close() + + queryLower := strings.ToLower(query) + normalizedTags := normalizeSlice(opts.tags) + projectLower := strings.ToLower(strings.TrimSpace(opts.project)) + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + entry, ok := parseEntry(line) + if !ok { + continue + } + if queryLower != "" && !strings.Contains(strings.ToLower(entry.text), queryLower) { + continue + } + if len(normalizedTags) > 0 && !entryHasTags(entry.text, normalizedTags) { + continue + } + if projectLower != "" && !entryHasProject(entry.text, projectLower) { + continue + } + if opts.since != nil || opts.until != nil { + entryDate := dateOnly(entry.timestamp) + if opts.since != nil && entryDate.Before(*opts.since) { + continue + } + if opts.until != nil && entryDate.After(*opts.until) { + continue + } + } + + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } + } + return scanner.Err() +} + +func normalizeSlice(values []string) []string { + normalized := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + normalized = append(normalized, strings.ToLower(trimmed)) + } + return normalized +} + +var tagRegex = regexp.MustCompile(`#([A-Za-z0-9_-]+)`) +var projectRegex = regexp.MustCompile(`(?i)\b(?:project|repo):([A-Za-z0-9_-]+)\b`) + +func entryHasTags(text string, tags []string) bool { + tagSet := map[string]struct{}{} + for _, match := range tagRegex.FindAllStringSubmatch(text, -1) { + if len(match) > 1 { + tagSet[strings.ToLower(match[1])] = struct{}{} + } + } + for _, tag := range tags { + if _, ok := tagSet[tag]; !ok { + return false + } + } + return true +} + +func entryHasProject(text string, project string) bool { + for _, match := range projectRegex.FindAllStringSubmatch(text, -1) { + if len(match) > 1 && strings.EqualFold(match[1], project) { + return true + } + } + return false +} + +func parseEntry(line string) (journalEntry, bool) { + if !strings.HasPrefix(line, "[") { + return journalEntry{}, false + } + end := strings.IndexByte(line, ']') + if end == -1 { + return journalEntry{}, false + } + timestampText := line[1:end] + timestamp, err := time.Parse("2006-01-02 15:04", timestampText) + if err != nil { + return journalEntry{}, false + } + text := strings.TrimSpace(line[end+1:]) + return journalEntry{timestamp: timestamp, text: text}, true +} + +func dateOnly(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) +} + func ensureJournal() (string, error) { home, err := os.UserHomeDir() if err != nil { @@ -168,6 +367,14 @@ func journalPaths(home string) (string, string) { return journalDir, journalPath } +func printUsage(w io.Writer) { + fmt.Fprintln(w, "usage: jot [init|list|patterns|search]") +} + +func printSearchUsage(w io.Writer) { + fmt.Fprintln(w, "usage: jot search [--tag ] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--project ]") +} + func isTTY(w io.Writer) bool { file, ok := w.(*os.File) if !ok { diff --git a/main_test.go b/main_test.go index 05850a6..cc16415 100644 --- a/main_test.go +++ b/main_test.go @@ -123,3 +123,86 @@ func TestJotInitAppendsWithTimestamp(t *testing.T) { t.Fatalf("expected entry %q, got %q", expectedEntry, string(data)) } } + +func TestJotSearchFiltersByTag(t *testing.T) { + 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] incident in prod #prod project:myrepo", + "[2024-01-02 11:00] incident in dev #dev project:other", + }, "\n") + "\n" + if err := os.WriteFile(journalPath, []byte(content), 0o600); err != nil { + t.Fatalf("write failed: %v", err) + } + + var out bytes.Buffer + opts := searchOptions{tags: []string{"prod"}} + if err := jotSearch(&out, "incident", opts); err != nil { + t.Fatalf("jotSearch returned error: %v", err) + } + + expected := "[2024-01-01 10:00] incident in prod #prod project:myrepo\n" + if out.String() != expected { + t.Fatalf("expected %q, got %q", expected, out.String()) + } +} + +func TestJotSearchFiltersByDate(t *testing.T) { + 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] incident in prod #prod", + "[2024-02-01 10:00] incident follow-up #prod", + }, "\n") + "\n" + if err := os.WriteFile(journalPath, []byte(content), 0o600); err != nil { + t.Fatalf("write failed: %v", err) + } + + var out bytes.Buffer + since := dateOnly(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)) + until := dateOnly(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC)) + opts := searchOptions{since: &since, until: &until} + if err := jotSearch(&out, "incident", opts); err != nil { + t.Fatalf("jotSearch returned error: %v", err) + } + + expected := "[2024-02-01 10:00] incident follow-up #prod\n" + if out.String() != expected { + t.Fatalf("expected %q, got %q", expected, out.String()) + } +} + +func TestJotSearchFiltersByProject(t *testing.T) { + 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-03-01 09:00] incident notes project:myrepo #prod", + "[2024-03-02 09:00] incident notes repo:other #prod", + }, "\n") + "\n" + if err := os.WriteFile(journalPath, []byte(content), 0o600); err != nil { + t.Fatalf("write failed: %v", err) + } + + var out bytes.Buffer + opts := searchOptions{project: "myrepo"} + if err := jotSearch(&out, "incident", opts); err != nil { + t.Fatalf("jotSearch returned error: %v", err) + } + + expected := "[2024-03-01 09:00] incident notes project:myrepo #prod\n" + if out.String() != expected { + t.Fatalf("expected %q, got %q", expected, out.String()) + } +}