// 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 }