diff --git a/README.md b/README.md index 99301e7..b77a7c7 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,20 @@ dispatch tags --json Tags come from sessions you have tagged in the TUI. Counts are taken against the current session store, so tags left on sessions that no longer exist are not counted. Use `--json` for scripting. +### Notes + +Manage session notes from the command line without editing `config.json` directly: + +```sh +dispatch notes +dispatch notes --json +dispatch notes get 0a1b2c3d +dispatch notes set 0a1b2c3d "follow up after review" +dispatch notes clear 0a1b2c3d +``` + +`dispatch notes` lists notes for sessions that still exist in the session store. `set`, `get`, and `clear` operate on one session ID and use the same notes shown in the TUI preview. + ### Named Views List named views and switch the active view from scripts: diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index 9ec1aee..ddaf685 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -163,6 +163,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd } return true, cleanup, startupOptions{}, nil + case "notes": + if nErr := runNotes(os.Stdout, args); nErr != nil { + fmt.Fprintf(os.Stderr, "notes: %v\n", nErr) + return true, cleanup, startupOptions{}, nErr + } + return true, cleanup, startupOptions{}, nil + case "views": if vErr := runViews(os.Stdout, args); vErr != nil { fmt.Fprintf(os.Stderr, "views: %v\n", vErr) diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index 545f30f..fd8954d 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -126,6 +126,7 @@ Commands: stats [flags] Print session totals and breakdowns search [query] [flags] Print matching sessions as JSON (no TUI) tags [--json] List tags in use with per-tag session counts + notes [command] List, get, set, or clear session notes views [command] List named views or set the active view config [get|set|list|edit|path] Read or change preferences (see Config commands) @@ -175,6 +176,12 @@ Config commands: config edit Open the config file in your editor config path Print the config file path +Notes commands: + notes [list] [--json] List notes attached to current sessions + notes get Print one session note + notes set Set one session note + notes clear Clear one session note + Export flags: --format md|json|html Output format (default md) --out Write to a directory instead of the exports folder diff --git a/cmd/dispatch/notes.go b/cmd/dispatch/notes.go new file mode 100644 index 0000000..3694f85 --- /dev/null +++ b/cmd/dispatch/notes.go @@ -0,0 +1,190 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/data" +) + +// notesListSessionsFn loads sessions for the notes command. It is a package +// variable so tests can substitute a fixed set of sessions. +var notesListSessionsFn = defaultStatsListSessions + +type noteEntry struct { + ID string `json:"id"` + Summary string `json:"summary"` + Note string `json:"note"` +} + +type notesReport struct { + TotalNotes int `json:"total_notes"` + Notes []noteEntry `json:"notes"` +} + +func runNotes(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + + rest := args + if len(rest) > 0 { + rest = rest[1:] + } + if len(rest) == 0 || rest[0] == "list" || rest[0] == "--json" { + return runNotesList(w, rest) + } + + switch rest[0] { + case "get": + return runNotesGet(w, rest[1:]) + case "set": + return runNotesSet(w, rest[1:]) + case "clear": + return runNotesClear(w, rest[1:]) + default: + return fmt.Errorf("unknown notes subcommand %q (want list, get, set, or clear)", rest[0]) + } +} + +func runNotesList(w io.Writer, args []string) error { + jsonOut := false + if len(args) > 0 && args[0] == "list" { + args = args[1:] + } + for _, arg := range args { + switch arg { + case "--json": + jsonOut = true + default: + return fmt.Errorf("notes list does not take arguments, got %q", arg) + } + } + + cfg, err := configLoadFn() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + sessions, err := notesListSessionsFn(data.FilterOptions{}) + if err != nil { + return err + } + report := buildNotesReport(cfg, sessions) + if jsonOut { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(report) + } + writeNotesText(w, report) + return nil +} + +func runNotesGet(w io.Writer, args []string) error { + if len(args) != 1 { + return fmt.Errorf("notes get requires a session ID") + } + cfg, err := configLoadFn() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + fmt.Fprintln(w, cfg.SessionNotes[args[0]]) + return nil +} + +func runNotesSet(w io.Writer, args []string) error { + if len(args) < 2 { + return fmt.Errorf("notes set requires a session ID and note text") + } + sessionID := args[0] + note := strings.Join(args[1:], " ") + if strings.TrimSpace(sessionID) == "" { + return fmt.Errorf("notes set requires a session ID") + } + cfg, err := configLoadFn() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + setSessionNote(cfg, sessionID, note) + if err := configSaveFn(cfg); err != nil { + return err + } + fmt.Fprintf(w, "Set note for %s\n", sessionID) + return nil +} + +func runNotesClear(w io.Writer, args []string) error { + if len(args) != 1 { + return fmt.Errorf("notes clear requires a session ID") + } + sessionID := args[0] + cfg, err := configLoadFn() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + setSessionNote(cfg, sessionID, "") + if err := configSaveFn(cfg); err != nil { + return err + } + fmt.Fprintf(w, "Cleared note for %s\n", sessionID) + return nil +} + +func setSessionNote(cfg *config.Config, sessionID, note string) { + if strings.TrimSpace(sessionID) == "" { + return + } + if note == "" { + delete(cfg.SessionNotes, sessionID) + return + } + if cfg.SessionNotes == nil { + cfg.SessionNotes = make(map[string]string) + } + cfg.SessionNotes[sessionID] = note +} + +func buildNotesReport(cfg *config.Config, sessions []data.Session) notesReport { + report := notesReport{Notes: []noteEntry{}} + if cfg == nil || len(cfg.SessionNotes) == 0 { + return report + } + + for _, s := range sessions { + note := cfg.SessionNotes[s.ID] + if note == "" { + continue + } + report.Notes = append(report.Notes, noteEntry{ID: s.ID, Summary: s.Summary, Note: note}) + } + sort.Slice(report.Notes, func(i, j int) bool { + return report.Notes[i].ID < report.Notes[j].ID + }) + report.TotalNotes = len(report.Notes) + return report +} + +func writeNotesText(w io.Writer, report notesReport) { + fmt.Fprintln(w, "Dispatch notes") + fmt.Fprintln(w) + if report.TotalNotes == 0 { + fmt.Fprintln(w, "No notes found.") + return + } + fmt.Fprintf(w, "Notes: %d\n\n", report.TotalNotes) + idWidth := 0 + for _, entry := range report.Notes { + if len(entry.ID) > idWidth { + idWidth = len(entry.ID) + } + } + for _, entry := range report.Notes { + fmt.Fprintf(w, " %-*s %s\n", idWidth, entry.ID, entry.Note) + if entry.Summary != "" { + fmt.Fprintf(w, " %-*s %s\n", idWidth, "", entry.Summary) + } + } +} diff --git a/cmd/dispatch/notes_test.go b/cmd/dispatch/notes_test.go new file mode 100644 index 0000000..1d2c0d6 --- /dev/null +++ b/cmd/dispatch/notes_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/data" +) + +func withNotesList(t *testing.T, fn func(data.FilterOptions) ([]data.Session, error)) { + t.Helper() + prev := notesListSessionsFn + notesListSessionsFn = fn + t.Cleanup(func() { notesListSessionsFn = prev }) +} + +func notedSessions() []data.Session { + return []data.Session{ + {ID: "b", Summary: "Build command"}, + {ID: "a", Summary: "Auth fix"}, + {ID: "c", Summary: "No note"}, + } +} + +func notedConfig() *config.Config { + cfg := config.Default() + cfg.SessionNotes = map[string]string{ + "a": "follow up", + "b": "ready to ship", + "z": "orphan note", + } + return cfg +} + +func TestBuildNotesReport(t *testing.T) { + report := buildNotesReport(notedConfig(), notedSessions()) + if report.TotalNotes != 2 { + t.Fatalf("TotalNotes = %d, want 2", report.TotalNotes) + } + if len(report.Notes) != 2 || report.Notes[0].ID != "a" || report.Notes[1].ID != "b" { + t.Fatalf("notes sorted by ID without orphans = %+v", report.Notes) + } +} + +func TestRunNotesListText(t *testing.T) { + withConfigSeams(t, notedConfig()) + withNotesList(t, func(data.FilterOptions) ([]data.Session, error) { return notedSessions(), nil }) + + var buf bytes.Buffer + if err := runNotes(&buf, []string{"notes"}); err != nil { + t.Fatalf("runNotes: %v", err) + } + out := buf.String() + for _, want := range []string{"Dispatch notes", "Notes: 2", "a", "follow up", "ready to ship"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "orphan note") { + t.Fatalf("orphan note should not appear:\n%s", out) + } +} + +func TestRunNotesListJSON(t *testing.T) { + withConfigSeams(t, notedConfig()) + withNotesList(t, func(data.FilterOptions) ([]data.Session, error) { return notedSessions(), nil }) + + var buf bytes.Buffer + if err := runNotes(&buf, []string{"notes", "--json"}); err != nil { + t.Fatalf("runNotes json: %v", err) + } + var report notesReport + if err := json.Unmarshal(buf.Bytes(), &report); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, buf.String()) + } + if report.TotalNotes != 2 || len(report.Notes) != 2 { + t.Fatalf("report = %+v, want 2 notes", report) + } +} + +func TestRunNotesGetSetClear(t *testing.T) { + cfg := withConfigSeams(t, config.Default()) + + var buf bytes.Buffer + if err := runNotes(&buf, []string{"notes", "set", "ses-1", "needs", "review"}); err != nil { + t.Fatalf("set note: %v", err) + } + if cfg.SessionNotes["ses-1"] != "needs review" { + t.Fatalf("stored note = %q", cfg.SessionNotes["ses-1"]) + } + + buf.Reset() + if err := runNotes(&buf, []string{"notes", "get", "ses-1"}); err != nil { + t.Fatalf("get note: %v", err) + } + if strings.TrimSpace(buf.String()) != "needs review" { + t.Fatalf("get output = %q", buf.String()) + } + + buf.Reset() + if err := runNotes(&buf, []string{"notes", "clear", "ses-1"}); err != nil { + t.Fatalf("clear note: %v", err) + } + if _, ok := cfg.SessionNotes["ses-1"]; ok { + t.Fatal("note should be removed") + } +} + +func TestRunNotesErrors(t *testing.T) { + withConfigSeams(t, config.Default()) + withNotesList(t, func(data.FilterOptions) ([]data.Session, error) { return nil, errors.New("boom") }) + for _, args := range [][]string{ + {"notes", "bogus"}, + {"notes", "get"}, + {"notes", "set", "ses-1"}, + {"notes", "clear"}, + {"notes", "list", "extra"}, + } { + if err := runNotes(&bytes.Buffer{}, args); err == nil { + t.Fatalf("expected error for args %v", args) + } + } +} + +func TestHandleArgsNotes(t *testing.T) { + withConfigSeams(t, notedConfig()) + withNotesList(t, func(data.FilterOptions) ([]data.Session, error) { return notedSessions(), nil }) + + done, _, _, err := handleArgs([]string{"notes"}, &bytes.Buffer{}, nil) + if err != nil { + t.Fatalf("handleArgs notes: %v", err) + } + if !done { + t.Fatal("handleArgs should report done for notes") + } +}