flake-update-20260505
1package org
2
3import (
4 "bufio"
5 "os"
6 "path/filepath"
7 "regexp"
8 "strings"
9 "time"
10)
11
12var (
13 // ** DONE [#2] Some task title :tag1:tag2:
14 orgHeadingRe = regexp.MustCompile(`^(\*+)\s+(TODO|DONE|STRT|NEXT|WAIT|CANX)\s+(?:\[#[^\]]*\]\s+)?(.+)$`)
15 // - State "DONE" from "TODO" [2026-01-25 Sat 15:30]
16 stateChangeRe = regexp.MustCompile(`^-\s+State\s+"(\w+)"\s+from\s+"(\w+)"\s+\[(\d{4}-\d{2}-\d{2}\s+\w{3}\s+\d{2}:\d{2})\]`)
17 // Org-mode links: [[url][description]]
18 orgLinkRe = regexp.MustCompile(`\[\[([^\]]+)\]\[([^\]]+)\]\]`)
19 // Org-mode tags at end of heading: :tag1:tag2:
20 orgTagsRe = regexp.MustCompile(`\s+:[A-Za-z0-9_@#%:]+:\s*$`)
21)
22
23// DoneItem represents a completed org task.
24type DoneItem struct {
25 Title string
26 Section string // top-level section (Work, Systems, Personal, etc.)
27 CompletedAt time.Time
28 File string
29}
30
31// FetchDoneItems returns tasks completed (transitioned to DONE) within the time range.
32// Scans the given files and optional archive directory.
33func FetchDoneItems(files []string, archiveDir string, start, end time.Time) ([]DoneItem, error) {
34 var items []DoneItem
35
36 for _, f := range files {
37 fileItems, err := parseDoneFromFile(f, start, end)
38 if err != nil {
39 continue
40 }
41 items = append(items, fileItems...)
42 }
43
44 if archiveDir != "" {
45 archiveItems, err := parseDoneFromDir(archiveDir, start, end)
46 if err == nil {
47 items = append(items, archiveItems...)
48 }
49 }
50
51 return items, nil
52}
53
54func parseDoneFromDir(dir string, start, end time.Time) ([]DoneItem, error) {
55 var items []DoneItem
56
57 resolved, err := filepath.EvalSymlinks(dir)
58 if err != nil {
59 resolved = dir
60 }
61
62 err = filepath.Walk(resolved, func(path string, info os.FileInfo, err error) error {
63 if err != nil || info.IsDir() {
64 return nil
65 }
66 ext := strings.ToLower(filepath.Ext(path))
67 if ext != ".org" && ext != "" {
68 return nil
69 }
70 // Skip backup/temp files
71 base := filepath.Base(path)
72 if strings.HasSuffix(base, "~") || strings.Contains(base, "#") {
73 return nil
74 }
75
76 fileItems, err := parseDoneFromFile(path, start, end)
77 if err == nil {
78 items = append(items, fileItems...)
79 }
80 return nil
81 })
82
83 return items, err
84}
85
86func parseDoneFromFile(filePath string, start, end time.Time) ([]DoneItem, error) {
87 f, err := os.Open(filePath)
88 if err != nil {
89 return nil, err
90 }
91 defer f.Close()
92
93 var items []DoneItem
94 var currentHeading string
95 var currentSection string
96 seen := make(map[string]bool)
97
98 scanner := bufio.NewScanner(f)
99 for scanner.Scan() {
100 line := scanner.Text()
101
102 // Track top-level section (single * heading without TODO state)
103 if strings.HasPrefix(line, "* ") && !strings.HasPrefix(line, "** ") {
104 if !orgHeadingRe.MatchString(line) {
105 section := strings.TrimPrefix(line, "* ")
106 section = orgTagsRe.ReplaceAllString(section, "")
107 currentSection = strings.TrimSpace(section)
108 }
109 }
110
111 // Track current heading
112 if matches := orgHeadingRe.FindStringSubmatch(line); len(matches) > 0 {
113 currentHeading = strings.TrimSpace(matches[3])
114 }
115
116 // Parse state changes to DONE
117 if matches := stateChangeRe.FindStringSubmatch(line); len(matches) > 0 {
118 if matches[1] != "DONE" || currentHeading == "" {
119 continue
120 }
121
122 ts, err := parseOrgTimestamp(matches[3])
123 if err != nil || ts.Before(start) || ts.After(end) {
124 continue
125 }
126
127 key := currentHeading + ts.Format("2006-01-02-15:04")
128 if seen[key] {
129 continue
130 }
131 seen[key] = true
132
133 items = append(items, DoneItem{
134 Title: cleanTitle(currentHeading),
135 Section: currentSection,
136 CompletedAt: ts,
137 File: filePath,
138 })
139 }
140 }
141
142 return items, scanner.Err()
143}
144
145func parseOrgTimestamp(s string) (time.Time, error) {
146 s = strings.TrimSpace(s)
147 formats := []string{
148 "2006-01-02 Mon 15:04",
149 "2006-01-02 Mon",
150 "2006-01-02",
151 }
152 // Normalize day names (org uses locale-dependent short names)
153 for _, dayName := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun",
154 "lun", "mar", "mer", "jeu", "ven", "sam", "dim",
155 "lun.", "mar.", "mer.", "jeu.", "ven.", "sam.", "dim."} {
156 s = strings.ReplaceAll(s, " "+dayName+" ", " Mon ")
157 s = strings.ReplaceAll(s, " "+dayName+"]", " Mon]")
158 }
159 for _, format := range formats {
160 if t, err := time.Parse(format, s); err == nil {
161 return t, nil
162 }
163 }
164 return time.Time{}, nil
165}
166
167// cleanTitle removes org artifacts from a heading title.
168func cleanTitle(s string) string {
169 // Remove trailing tags
170 s = orgTagsRe.ReplaceAllString(s, "")
171 // Convert [[url][desc]] links to just desc
172 s = orgLinkRe.ReplaceAllString(s, "$2")
173 return strings.TrimSpace(s)
174}