package store import ( "encoding/json" "fmt" "sort" "strings" "time" ) // TockStore implements Store by shelling out to the tock CLI. type TockStore struct{ R Runner } // New returns a TockStore over the given Runner. func New(r Runner) *TockStore { return &TockStore{R: r} } type rawEntry struct { Description string `json:"description"` Project string `json:"project"` StartTime string `json:"start_time"` EndTime string `json:"end_time"` Tags []string `json:"tags"` Notes string `json:"notes"` Duration string `json:"duration"` } func parseTime(s string) (time.Time, error) { if strings.TrimSpace(s) == "" { return time.Time{}, nil } return time.Parse(time.RFC3339Nano, s) } func parseDur(s string) time.Duration { var h, m, sec int fmt.Sscanf(strings.TrimSpace(s), "%d:%d:%d", &h, &m, &sec) return time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(sec)*time.Second } // Day returns a day's entries sorted by start, each stamped with its tock Key. func (t *TockStore) Day(date string) ([]Entry, error) { out, _ := t.R.Run("tock", "report", "--date", date, "--json") if len(strings.TrimSpace(string(out))) == 0 { return nil, nil // tock prints nothing on an empty day } var raws []rawEntry if err := json.Unmarshal(out, &raws); err != nil { return nil, fmt.Errorf("parse tock report: %w", err) } entries := make([]Entry, 0, len(raws)) for _, r := range raws { st, err := parseTime(r.StartTime) if err != nil { return nil, fmt.Errorf("parse start_time %q: %w", r.StartTime, err) } et, err := parseTime(r.EndTime) if err != nil { return nil, fmt.Errorf("parse end_time %q: %w", r.EndTime, err) } entries = append(entries, Entry{ Start: st, End: et, Project: r.Project, Description: r.Description, Tags: r.Tags, Notes: r.Notes, Duration: parseDur(r.Duration), }) } sort.SliceStable(entries, func(i, j int) bool { return entries[i].Start.Before(entries[j].Start) }) for i := range entries { entries[i].Key = fmt.Sprintf("%s-%02d", date, i+1) } return entries, nil } func fmtStamp(t time.Time) string { return t.Format("2006-01-02 15:04") } // Add creates a tock entry. func (t *TockStore) Add(e Entry) error { args := []string{"add", "-p", e.Project, "-d", e.Description, "-s", fmtStamp(e.Start), "-e", fmtStamp(e.End)} for _, tag := range e.Tags { if tag = strings.TrimSpace(tag); tag != "" { args = append(args, "--tag", tag) } } if strings.TrimSpace(e.Notes) != "" { args = append(args, "--note", e.Notes) } _, err := t.R.Run("tock", args...) return err } // Remove deletes the entry with the given date-index Key. func (t *TockStore) Remove(key string) error { _, err := t.R.Run("tock", "remove", key, "-y") return err } // SafeReplace removes then re-adds an entry. If the replacement Add fails it // restores the original, so an edit can never silently delete an entry. func (t *TockStore) SafeReplace(key string, updated, original Entry) error { if err := t.Remove(key); err != nil { return fmt.Errorf("remove %s: %w", key, err) } if err := t.Add(updated); err != nil { if rerr := t.Add(original); rerr != nil { return fmt.Errorf("add failed (%v) AND restore failed (%v); entry %s may be lost", err, rerr, key) } return fmt.Errorf("edit rejected, original restored: %w", err) } return nil }