▍ humdrum codex / ticktock v0.0.2
license AGPL-3.0
3.3 KB raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
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
}