feat: log/clear/note commands with overwrite confirmation gate
76e77ecf735434985e37c9094697c1646aa87252
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-29 17:22
parent 1e954ed2
5 files changed
cmd/dots/main.go +1 −1
@@ -16,7 +16,7 @@ Short: "Log your day from the terminal",
SilenceUsage: true,
SilenceErrors: true,
}
- root.AddCommand(cli.NewAuthCmd())
+ root.AddCommand(cli.NewAuthCmd(), cli.NewLogCmd(), cli.NewClearCmd(), cli.NewNoteCmd())
return root
}
internal/cli/confirm.go +46 −0
@@ -0,0 +1,46 @@
+package cli
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/humdrum-tiv/dots-cli/internal/day"
+)
+
+// FormatChanges renders the pre-write diff table shown before confirmation.
+func FormatChanges(changes []day.Change, typeNames map[string]string, blockSize int) string {
+ name := func(id *string) string {
+ if id == nil {
+ return "(cleared)"
+ }
+ if n, ok := typeNames[*id]; ok {
+ return n
+ }
+ return *id
+ }
+ var b strings.Builder
+ for _, c := range changes {
+ from := "(empty)"
+ if c.FromID != nil {
+ from = name(c.FromID)
+ }
+ fmt.Fprintf(&b, " %-6s %s → %s\n", day.SlotLabel(c.Index, blockSize), from, name(c.ToID))
+ }
+ return b.String()
+}
+
+// Confirm enforces the no-silent-overwrite rule: --yes bypasses, a real TTY
+// prompts y/N (default N), and non-TTY without --yes refuses outright.
+func Confirm(prompt string, yes bool, in io.Reader, out io.Writer, isTTY bool) (bool, error) {
+ if yes {
+ return true, nil
+ }
+ if !isTTY {
+ return false, fmt.Errorf("would change existing blocks — re-run with --yes (non-interactive)")
+ }
+ fmt.Fprintf(out, "%s [y/N] ", prompt)
+ line, _ := bufio.NewReader(in).ReadString('\n')
+ return strings.TrimSpace(strings.ToLower(line)) == "y", nil
+}
internal/cli/confirm_test.go +60 −0
@@ -0,0 +1,60 @@
+package cli
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/humdrum-tiv/dots-cli/internal/day"
+)
+
+func sp(s string) *string { return &s }
+
+func TestFormatChanges(t *testing.T) {
+ names := map[string]string{"t1": "archer", "t2": "foofaraw"}
+ changes := []day.Change{
+ {Index: 29, FromID: sp("t1"), ToID: sp("t2")},
+ {Index: 30, FromID: sp("t1"), ToID: nil},
+ }
+ out := FormatChanges(changes, names, 30)
+ if !strings.Contains(out, "14:30") || !strings.Contains(out, "archer → foofaraw") {
+ t.Fatalf("out %q", out)
+ }
+ if !strings.Contains(out, "15:00") || !strings.Contains(out, "archer → (cleared)") {
+ t.Fatalf("out %q", out)
+ }
+}
+
+func TestConfirm(t *testing.T) {
+ // --yes bypasses everything
+ ok, err := Confirm("overwrite?", true, strings.NewReader(""), &strings.Builder{}, false)
+ if err != nil || !ok {
+ t.Fatalf("yes flag: ok=%v err=%v", ok, err)
+ }
+ // non-TTY without --yes = hard error
+ if _, err := Confirm("overwrite?", false, strings.NewReader("y\n"), &strings.Builder{}, false); err == nil {
+ t.Fatal("non-TTY without --yes must error")
+ }
+ // TTY: y accepts, anything else declines
+ ok, _ = Confirm("overwrite?", false, strings.NewReader("y\n"), &strings.Builder{}, true)
+ if !ok {
+ t.Fatal("y should accept")
+ }
+ ok, _ = Confirm("overwrite?", false, strings.NewReader("n\n"), &strings.Builder{}, true)
+ if ok {
+ t.Fatal("n should decline")
+ }
+ ok, _ = Confirm("overwrite?", false, strings.NewReader("\n"), &strings.Builder{}, true)
+ if ok {
+ t.Fatal("empty should decline (default N)")
+ }
+ // y without newline (EOF) should accept
+ ok, _ = Confirm("overwrite?", false, strings.NewReader("y"), &strings.Builder{}, true)
+ if !ok {
+ t.Fatal("y without newline should accept")
+ }
+ // immediate EOF (empty input) should decline
+ ok, _ = Confirm("overwrite?", false, strings.NewReader(""), &strings.Builder{}, true)
+ if ok {
+ t.Fatal("immediate EOF should decline")
+ }
+}
internal/cli/log.go +190 −0
@@ -0,0 +1,190 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/spf13/cobra"
+ "golang.org/x/term"
+
+ "github.com/humdrum-tiv/dots-cli/internal/day"
+)
+
+// timeNow is a seam so command tests can pin the clock.
+var timeNow = func() time.Time { return time.Now() }
+
+func stdinIsTTY() bool { return term.IsTerminal(int(os.Stdin.Fd())) }
+
+func typeNames(types []day.ActivityType) map[string]string {
+ m := make(map[string]string, len(types))
+ for _, t := range types {
+ m[t.ID] = t.Name
+ }
+ return m
+}
+
+// pickActivity resolves a query to exactly one type or errors.
+func pickActivity(query string, types []day.ActivityType) (*day.ActivityType, error) {
+ matches := day.MatchActivity(query, types)
+ switch len(matches) {
+ case 1:
+ return &matches[0], nil
+ case 0:
+ names := make([]string, len(types))
+ for i, t := range types {
+ names[i] = t.Name
+ }
+ return nil, fmt.Errorf("no activity matches %q — have: %v", query, names)
+ default:
+ if !stdinIsTTY() {
+ return nil, fmt.Errorf("%q is ambiguous: %v", query, namesOf(matches))
+ }
+ fmt.Println("Which one?")
+ for i, m := range matches {
+ fmt.Printf(" %d) %s\n", i+1, m.Name)
+ }
+ fmt.Print("> ")
+ var n int
+ if _, err := fmt.Scanln(&n); err != nil || n < 1 || n > len(matches) {
+ return nil, fmt.Errorf("no selection")
+ }
+ return &matches[n-1], nil
+ }
+}
+
+func namesOf(ts []day.ActivityType) []string {
+ out := make([]string, len(ts))
+ for i, t := range ts {
+ out[i] = t.Name
+ }
+ return out
+}
+
+func NewLogCmd() *cobra.Command {
+ var date string
+ var yes bool
+ cmd := &cobra.Command{
+ Use: "log [time|range] [activity]",
+ Short: "Paint logged block(s); no activity = fill from plan",
+ Args: cobra.MaximumNArgs(2),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ client, err := RequireClient()
+ if err != nil {
+ return err
+ }
+ // Positional parsing: first arg is a time only if it looks like one.
+ target, activityQuery := "", ""
+ switch len(args) {
+ case 1:
+ if day.IsTimeSyntax(args[0]) {
+ target = args[0]
+ } else {
+ activityQuery = args[0]
+ }
+ case 2:
+ if !day.IsTimeSyntax(args[0]) {
+ return fmt.Errorf("first argument %q is not a time — usage: dots log [time|range] [activity]", args[0])
+ }
+ target, activityQuery = args[0], args[1]
+ }
+
+ if date == "" {
+ date = Today()
+ }
+ d, err := client.GetDay(date, TZ())
+ if err != nil {
+ return err
+ }
+ targets, err := day.ParseTarget(target, d.BlockSize, timeNow())
+ if err != nil {
+ return err
+ }
+
+ var activityID *string
+ if activityQuery != "" {
+ t, err := pickActivity(activityQuery, d.Types)
+ if err != nil {
+ return err
+ }
+ activityID = &t.ID
+ }
+
+ assignments, overwrites, err := day.ResolveLog(targets, activityID, d.Blocks, d.PlanBlocks, d.BlockSize)
+ if err != nil {
+ return err
+ }
+ if len(overwrites) > 0 {
+ fmt.Print(FormatChanges(overwrites, typeNames(d.Types), d.BlockSize))
+ ok, err := Confirm("Overwrite?", yes, os.Stdin, os.Stdout, stdinIsTTY())
+ if err != nil {
+ return err
+ }
+ if !ok {
+ fmt.Println("Aborted, nothing written.")
+ return nil
+ }
+ }
+ if err := client.WriteBlocks(date, assignments); err != nil {
+ return err
+ }
+ fmt.Printf("Logged %d block(s).\n", len(assignments))
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&date, "date", "", "day to write (YYYY-MM-DD, default today)")
+ cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation prompts")
+ return cmd
+}
+
+func NewClearCmd() *cobra.Command {
+ var date string
+ var yes bool
+ cmd := &cobra.Command{
+ Use: "clear <time|range>",
+ Short: "Clear logged block(s)",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ client, err := RequireClient()
+ if err != nil {
+ return err
+ }
+ if !day.IsTimeSyntax(args[0]) {
+ return fmt.Errorf("%q is not a time or range", args[0])
+ }
+ if date == "" {
+ date = Today()
+ }
+ d, err := client.GetDay(date, TZ())
+ if err != nil {
+ return err
+ }
+ targets, err := day.ParseTarget(args[0], d.BlockSize, timeNow())
+ if err != nil {
+ return err
+ }
+ assignments, cleared := day.ResolveClear(targets, d.Blocks)
+ if len(cleared) == 0 {
+ fmt.Println("Nothing logged there.")
+ return nil
+ }
+ fmt.Print(FormatChanges(cleared, typeNames(d.Types), d.BlockSize))
+ ok, err := Confirm("Clear?", yes, os.Stdin, os.Stdout, stdinIsTTY())
+ if err != nil {
+ return err
+ }
+ if !ok {
+ fmt.Println("Aborted, nothing cleared.")
+ return nil
+ }
+ if err := client.WriteBlocks(date, assignments); err != nil {
+ return err
+ }
+ fmt.Printf("Cleared %d block(s).\n", len(cleared))
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&date, "date", "", "day to write (YYYY-MM-DD, default today)")
+ cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation prompts")
+ return cmd
+}
internal/cli/note.go +37 −0
@@ -0,0 +1,37 @@
+package cli
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+func NewNoteCmd() *cobra.Command {
+ var date string
+ cmd := &cobra.Command{
+ Use: "note <text>",
+ Short: "Append a note to the day",
+ Args: cobra.MinimumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ client, err := RequireClient()
+ if err != nil {
+ return err
+ }
+ if date == "" {
+ date = Today()
+ }
+ content := strings.TrimSpace(strings.Join(args, " "))
+ if content == "" {
+ return fmt.Errorf("empty note")
+ }
+ if err := client.AddNote(date, content); err != nil {
+ return err
+ }
+ fmt.Println("Noted.")
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&date, "date", "", "day to write (YYYY-MM-DD, default today)")
+ return cmd
+}