flake-update-20260201
  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}