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}