auto-update-daily-20260202
1package sources
2
3import (
4 "bufio"
5 "context"
6 "os"
7 "path/filepath"
8 "regexp"
9 "strings"
10 "time"
11
12 "github.com/vdemeester/home/tools/review-tool/internal/activity"
13 "github.com/vdemeester/home/tools/review-tool/internal/config"
14)
15
16var (
17 // **Date:** 2026-01-21
18 mdDateRe = regexp.MustCompile(`\*\*Date:\*\*\s+(\d{4}-\d{2}-\d{2})`)
19 // # Title or # Some Title Here
20 mdTitleRe = regexp.MustCompile(`^#\s+(.+)$`)
21 // #+title: SelfCI Analysis
22 orgTitleRe = regexp.MustCompile(`^\s*#\+title:\s*(.+)$`)
23 // #+date: [2026-01-16 Thu]
24 orgDateRe = regexp.MustCompile(`^\s*#\+date:\s*\[(\d{4}-\d{2}-\d{2})[^\]]*\]`)
25 // Filename patterns: 2026-01-21-something.md or 20260121-something.md
26 filenameDateRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})`)
27 filenameDateCompactRe = regexp.MustCompile(`^(\d{8})`)
28)
29
30// ClaudeSource fetches activity from Claude session history.
31type ClaudeSource struct {
32 cfg *config.ClaudeConfig
33}
34
35// NewClaudeSource creates a new Claude history source.
36func NewClaudeSource(cfg *config.ClaudeConfig) *ClaudeSource {
37 return &ClaudeSource{cfg: cfg}
38}
39
40// Name returns the source identifier.
41func (c *ClaudeSource) Name() string {
42 return "claude"
43}
44
45// Validate checks if history directory exists.
46func (c *ClaudeSource) Validate() error {
47 if _, err := os.Stat(c.cfg.HistoryDir); err != nil {
48 return err
49 }
50 return nil
51}
52
53// Fetch retrieves Claude session activities within the time range.
54func (c *ClaudeSource) Fetch(ctx context.Context, start, end time.Time) (*activity.Activity, error) {
55 act := &activity.Activity{
56 Source: "claude",
57 Items: []activity.ActivityItem{},
58 }
59
60 if c.cfg.IncludeSessions {
61 items, err := c.scanDirectory(filepath.Join(c.cfg.HistoryDir, "sessions"), "session", start, end)
62 if err == nil {
63 act.Items = append(act.Items, items...)
64 }
65 }
66
67 if c.cfg.IncludeLearnings {
68 items, err := c.scanDirectory(filepath.Join(c.cfg.HistoryDir, "learnings"), "learning", start, end)
69 if err == nil {
70 act.Items = append(act.Items, items...)
71 }
72 }
73
74 if c.cfg.IncludeResearch {
75 items, err := c.scanDirectory(filepath.Join(c.cfg.HistoryDir, "research"), "research", start, end)
76 if err == nil {
77 act.Items = append(act.Items, items...)
78 }
79 }
80
81 return act, nil
82}
83
84func (c *ClaudeSource) scanDirectory(baseDir, itemType string, start, end time.Time) ([]activity.ActivityItem, error) {
85 var items []activity.ActivityItem
86
87 // Walk through YYYY-MM subdirectories
88 err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
89 if err != nil {
90 return nil // Skip errors, continue walking
91 }
92
93 if info.IsDir() {
94 return nil
95 }
96
97 // Only process .md and .org files
98 ext := strings.ToLower(filepath.Ext(path))
99 if ext != ".md" && ext != ".org" {
100 return nil
101 }
102
103 // Skip small files (likely not content files)
104 if info.Size() < 50 {
105 return nil
106 }
107
108 item, err := c.parseFile(path, itemType, ext)
109 if err != nil {
110 return nil // Skip files that fail to parse
111 }
112
113 // Filter by date range
114 if item.Timestamp.Before(start) || item.Timestamp.After(end) {
115 return nil
116 }
117
118 items = append(items, *item)
119 return nil
120 })
121
122 return items, err
123}
124
125func (c *ClaudeSource) parseFile(path, itemType, ext string) (*activity.ActivityItem, error) {
126 file, err := os.Open(path)
127 if err != nil {
128 return nil, err
129 }
130 defer file.Close()
131
132 var title string
133 var dateStr string
134 filename := filepath.Base(path)
135
136 scanner := bufio.NewScanner(file)
137 lineCount := 0
138 for scanner.Scan() && lineCount < 30 {
139 line := scanner.Text()
140 lineCount++
141
142 // Parse based on file type
143 if ext == ".org" {
144 // Org-mode: #+title: and #+date:
145 if matches := orgTitleRe.FindStringSubmatch(line); len(matches) > 1 && title == "" {
146 title = strings.TrimSpace(matches[1])
147 }
148 if matches := orgDateRe.FindStringSubmatch(line); len(matches) > 1 && dateStr == "" {
149 dateStr = matches[1]
150 }
151 } else {
152 // Markdown: # Title and **Date:**
153 if matches := mdTitleRe.FindStringSubmatch(line); len(matches) > 1 && title == "" {
154 title = strings.TrimSpace(matches[1])
155 }
156 if matches := mdDateRe.FindStringSubmatch(line); len(matches) > 1 && dateStr == "" {
157 dateStr = matches[1]
158 }
159 }
160
161 // Early exit if we have both
162 if title != "" && dateStr != "" {
163 break
164 }
165 }
166
167 // Fallback: extract date from filename
168 if dateStr == "" {
169 if matches := filenameDateRe.FindStringSubmatch(filename); len(matches) > 1 {
170 dateStr = matches[1]
171 } else if matches := filenameDateCompactRe.FindStringSubmatch(filename); len(matches) > 1 {
172 // Convert 20260121 to 2026-01-21
173 d := matches[1]
174 dateStr = d[:4] + "-" + d[4:6] + "-" + d[6:8]
175 }
176 }
177
178 // Fallback title: use filename without extension and date prefix
179 if title == "" {
180 title = cleanFilename(filename)
181 }
182
183 // Parse date
184 ts := time.Now() // fallback
185 if dateStr != "" {
186 if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
187 ts = parsed
188 }
189 }
190
191 return &activity.ActivityItem{
192 ID: path,
193 Title: title,
194 Type: itemType,
195 Category: activity.CategoryClaude,
196 Timestamp: ts,
197 Metadata: map[string]string{
198 "path": path,
199 },
200 }, nil
201}
202
203func cleanFilename(filename string) string {
204 // Remove extension
205 name := strings.TrimSuffix(filename, filepath.Ext(filename))
206
207 // Remove date prefixes
208 name = filenameDateRe.ReplaceAllString(name, "")
209 name = filenameDateCompactRe.ReplaceAllString(name, "")
210
211 // Remove common suffixes
212 name = strings.TrimPrefix(name, "-")
213 name = strings.TrimPrefix(name, "_")
214
215 // Convert underscores and hyphens to spaces, title case
216 name = strings.ReplaceAll(name, "-", " ")
217 name = strings.ReplaceAll(name, "_", " ")
218
219 // Remove timestamp patterns like 145851_SESSION_
220 name = regexp.MustCompile(`^\d{6}_\w+_`).ReplaceAllString(name, "")
221
222 return strings.TrimSpace(name)
223}