Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
209 changes: 208 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package main
import (
"bufio"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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(), " "))
Comment on lines +186 to +190

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse flags before query or filters are ignored

Go’s flag.FlagSet.Parse stops at the first non-flag argument, so with the documented usage jot search "incident" --tag prod the parser will stop at incident and leave --tag prod in flags.Args(). That means opts.tags/since/until/project stay empty and the query becomes incident --tag prod, silently disabling filtering. This makes the new filters unusable unless users put all flags before the query (or use --). Consider supporting interspersed flags (e.g., custom parsing or pflag) or enforcing/communicating a flags-before-query convention.

Useful? React with 👍 / 👎.

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 {
Expand Down Expand Up @@ -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 <query> [--tag <tag>] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--project <name>]")
}

func isTTY(w io.Writer) bool {
file, ok := w.(*os.File)
if !ok {
Expand Down
83 changes: 83 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}