Skip to content
Merged
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions cmd/dispatch/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <id> Print one session note
notes set <id> <text> Set one session note
notes clear <id> Clear one session note

Export flags:
--format md|json|html Output format (default md)
--out <dir> Write to a directory instead of the exports folder
Expand Down
190 changes: 190 additions & 0 deletions cmd/dispatch/notes.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
140 changes: 140 additions & 0 deletions cmd/dispatch/notes_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}