▍ humdrum codex / ticktock v0.0.2
license AGPL-3.0
4.2 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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
// Package history reads distinct project/description/tag values from the user's
// tock data for autocomplete. Read-only; tock remains the source of truth.
package history

import (
	"encoding/json"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
)

// History holds recency-ordered distinct values for autocomplete.
type History struct {
	Projects, Descriptions, Tags []string
}

type runner interface {
	run(name string, args ...string) ([]byte, error)
}

type execRunner struct{}

func (execRunner) run(name string, args ...string) ([]byte, error) {
	return exec.Command(name, args...).Output()
}

// Load reads history using the real environment. Never errors: it degrades to
// the tock-last fallback and finally to an empty History.
func Load() History {
	home, err := os.UserHomeDir()
	cfgPath := ""
	if err == nil {
		cfgPath = filepath.Join(home, ".config", "tock", "tock.yaml")
	}
	return loadWith(execRunner{}, cfgPath)
}

const (
	qProjects     = "SELECT project FROM activities WHERE project<>'' GROUP BY project ORDER BY MAX(start_time) DESC;"
	qDescriptions = "SELECT description FROM activities WHERE description<>'' GROUP BY description ORDER BY MAX(start_time) DESC;"
	qTags         = "SELECT je.value FROM activities, json_each(activities.tags) je WHERE activities.tags IS NOT NULL AND activities.tags NOT IN ('','[]') GROUP BY je.value ORDER BY MAX(activities.start_time) DESC;"
)

func loadWith(r runner, cfgPath string) History {
	backend, dbPath := "", ""
	if cfgPath != "" {
		if b, err := os.ReadFile(cfgPath); err == nil {
			backend, dbPath = parseTockConfig(b)
		}
	}
	dbPath = expandHome(dbPath)
	if backend == "sqlite" && dbPath != "" && fileExists(dbPath) {
		if h, ok := loadSqlite(r, dbPath); ok {
			return h
		}
	}
	return loadTockLast(r)
}

func loadSqlite(r runner, dbPath string) (History, bool) {
	proj, err1 := r.run("sqlite3", dbPath, qProjects)
	desc, err2 := r.run("sqlite3", dbPath, qDescriptions)
	tags, err3 := r.run("sqlite3", dbPath, qTags)
	if err1 != nil || err2 != nil || err3 != nil {
		return History{}, false
	}
	return History{
		Projects:     parseLines(proj),
		Descriptions: parseLines(desc),
		Tags:         parseLines(tags),
	}, true
}

type lastEntry struct {
	Project     string   `json:"project"`
	Description string   `json:"description"`
	Tags        []string `json:"tags"`
}

func loadTockLast(r runner) History {
	out, err := r.run("tock", "last", "-n", "500", "--json")
	if err != nil {
		return History{}
	}
	var raw []lastEntry
	if json.Unmarshal(out, &raw) != nil {
		return History{}
	}
	return dedupeEntries(raw)
}

// parseTockConfig scans for `backend:` and the `path:` under the `sqlite:` block.
func parseTockConfig(b []byte) (string, string) {
	var backend, sqlitePath string
	inSqlite := false
	for _, line := range strings.Split(string(b), "\n") {
		trimmed := strings.TrimSpace(line)
		indented := line != trimmed && strings.HasPrefix(line, " ")
		if !indented {
			inSqlite = strings.HasPrefix(trimmed, "sqlite:")
			if strings.HasPrefix(trimmed, "backend:") {
				backend = strings.TrimSpace(strings.TrimPrefix(trimmed, "backend:"))
			}
			continue
		}
		if inSqlite && strings.HasPrefix(trimmed, "path:") {
			sqlitePath = strings.TrimSpace(strings.TrimPrefix(trimmed, "path:"))
		}
	}
	return backend, sqlitePath
}

func parseLines(b []byte) []string {
	var out []string
	for _, line := range strings.Split(string(b), "\n") {
		if s := strings.TrimSpace(line); s != "" {
			out = append(out, s)
		}
	}
	return out
}

func dedupeEntries(raw []lastEntry) History {
	var h History
	sp, sd, st := map[string]bool{}, map[string]bool{}, map[string]bool{}
	for _, e := range raw {
		if e.Project != "" && !sp[e.Project] {
			sp[e.Project] = true
			h.Projects = append(h.Projects, e.Project)
		}
		if e.Description != "" && !sd[e.Description] {
			sd[e.Description] = true
			h.Descriptions = append(h.Descriptions, e.Description)
		}
		for _, tg := range e.Tags {
			if tg != "" && !st[tg] {
				st[tg] = true
				h.Tags = append(h.Tags, tg)
			}
		}
	}
	return h
}

func expandHome(p string) string {
	if strings.HasPrefix(p, "~/") {
		if home, err := os.UserHomeDir(); err == nil {
			return filepath.Join(home, p[2:])
		}
	}
	return p
}

func fileExists(p string) bool {
	_, err := os.Stat(p)
	return err == nil
}