โ– humdrum codex / ticktock v0.0.2
license AGPL-3.0
2.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
package autotrack

import (
	"bytes"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"time"
)

// stampLayout matches Python isoformat(timespec="seconds"): local time, no
// offset, e.g. 2026-07-07T08:59:19.
const stampLayout = "2006-01-02T15:04:05"

// Record is one on-disk activity line: {start, end, secs, app, title, url,
// domain, idle} โ€” byte-compatible with the Python daemon's
// json.dumps(..., ensure_ascii=False) output.
type Record struct {
	Start, End              time.Time
	Secs                    int
	App, Title, URL, Domain string
	Idle                    bool
}

// jsonStr encodes s as a JSON string without HTML escaping (parity with
// Python, which writes <, >, & and non-ASCII raw).
func jsonStr(s string) string {
	var b bytes.Buffer
	enc := json.NewEncoder(&b)
	enc.SetEscapeHTML(false)
	_ = enc.Encode(s) // encoding a plain string cannot fail
	return string(bytes.TrimRight(b.Bytes(), "\n"))
}

// EncodeLine renders the record exactly as the Python daemon did, including
// json.dumps' ", " / ": " separators and key order.
func (r Record) EncodeLine() string {
	return fmt.Sprintf(
		`{"start": %q, "end": %q, "secs": %d, "app": %s, "title": %s, "url": %s, "domain": %s, "idle": %t}`,
		r.Start.Format(stampLayout), r.End.Format(stampLayout), r.Secs,
		jsonStr(r.App), jsonStr(r.Title), jsonStr(r.URL), jsonStr(r.Domain), r.Idle)
}

// LogPath is the daily staging log for the given moment under dir.
func LogPath(dir string, when time.Time) string {
	return filepath.Join(dir, "activity-"+when.Format("2006-01-02")+".jsonl")
}

// WriteSegment appends seg to its start-day's log under dir, creating dir if
// needed. Segments shorter than minSecs are dropped as noise. nil is a no-op.
func WriteSegment(dir string, seg *Segment, minSecs int) error {
	if seg == nil {
		return nil
	}
	secs := seg.End.Sub(seg.Start).Seconds()
	if secs < float64(minSecs) {
		return nil
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	rec := Record{
		Start: seg.Start, End: seg.End, Secs: int(secs),
		App: seg.App, Title: seg.Title, URL: seg.URL, Domain: seg.Domain, Idle: seg.Idle,
	}
	f, err := os.OpenFile(LogPath(dir, seg.Start), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return err
	}
	defer f.Close()
	_, err = f.WriteString(rec.EncodeLine() + "\n")
	return err
}