main
  1// Package org provides org-mode integration for scheduling TODOs.
  2package org
  3
  4import (
  5	"bufio"
  6	"bytes"
  7	"fmt"
  8	"os"
  9	"os/exec"
 10	"regexp"
 11	"strings"
 12	"time"
 13)
 14
 15// ScheduledItem represents an org TODO scheduled for a specific day.
 16type ScheduledItem struct {
 17	Heading string
 18	State   string // TODO, DONE, STRT, etc.
 19}
 20
 21// TodayItems returns org items scheduled for today from the given file.
 22func TodayItems(orgFile string) ([]ScheduledItem, error) {
 23	today := time.Now().Format("2006-01-02")
 24	return itemsForDate(orgFile, today)
 25}
 26
 27// DeadlineItem represents an org TODO with a DEADLINE in a date range.
 28type DeadlineItem struct {
 29	Heading   string
 30	State     string    // TODO, NEXT, STRT, WAIT
 31	Deadline  string    // YYYY-MM-DD
 32	Scheduled bool      // whether the item is also SCHEDULED
 33	Date      time.Time // parsed deadline, for sorting
 34}
 35
 36var deadlineRe = regexp.MustCompile(`DEADLINE:\s+<(\d{4}-\d{2}-\d{2})`)
 37
 38// UpcomingDeadlines returns non-DONE org items whose DEADLINE falls within
 39// [today, today+days]. The Scheduled flag reports whether the same item also
 40// carries a SCHEDULED stamp (org emits planning lines on the line following the
 41// heading, scheduled/deadline possibly combined on one line).
 42func UpcomingDeadlines(orgFile string, days int) ([]DeadlineItem, error) {
 43	f, err := os.Open(orgFile)
 44	if err != nil {
 45		return nil, err
 46	}
 47	defer f.Close()
 48
 49	now := time.Now()
 50	today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
 51	until := today.AddDate(0, 0, days)
 52
 53	var items []DeadlineItem
 54	scanner := bufio.NewScanner(f)
 55	var prevLine string
 56	for scanner.Scan() {
 57		line := scanner.Text()
 58		m := deadlineRe.FindStringSubmatch(line)
 59		if m == nil {
 60			prevLine = line
 61			continue
 62		}
 63		d, perr := time.ParseInLocation("2006-01-02", m[1], now.Location())
 64		if perr != nil || d.Before(today) || d.After(until) {
 65			prevLine = line
 66			continue
 67		}
 68		heading := strings.TrimSpace(prevLine)
 69		if !strings.HasPrefix(heading, "*") {
 70			prevLine = line
 71			continue
 72		}
 73		for strings.HasPrefix(heading, "*") {
 74			heading = strings.TrimPrefix(heading, "*")
 75		}
 76		heading = strings.TrimSpace(heading)
 77		state := ""
 78		for _, s := range []string{"TODO", "DONE", "NEXT", "STRT", "WAIT", "CANX"} {
 79			if strings.HasPrefix(heading, s+" ") {
 80				state = s
 81				heading = strings.TrimPrefix(heading, s+" ")
 82				break
 83			}
 84		}
 85		if state == "DONE" || state == "CANX" {
 86			prevLine = line
 87			continue
 88		}
 89		items = append(items, DeadlineItem{
 90			Heading:   strings.TrimSpace(heading),
 91			State:     state,
 92			Deadline:  m[1],
 93			Scheduled: strings.Contains(line, "SCHEDULED:"),
 94			Date:      d,
 95		})
 96		prevLine = line
 97	}
 98	return items, scanner.Err()
 99}
100
101func itemsForDate(orgFile, date string) ([]ScheduledItem, error) {
102	f, err := os.Open(orgFile)
103	if err != nil {
104		return nil, err
105	}
106	defer f.Close()
107
108	var items []ScheduledItem
109	scanner := bufio.NewScanner(f)
110	var prevLine string
111	for scanner.Scan() {
112		line := scanner.Text()
113		if strings.Contains(line, fmt.Sprintf("SCHEDULED: <%s", date)) ||
114			strings.Contains(line, fmt.Sprintf("DEADLINE: <%s", date)) {
115			// Previous line should be the heading
116			if strings.HasPrefix(strings.TrimSpace(prevLine), "**") {
117				heading := strings.TrimSpace(prevLine)
118				// Strip leading stars
119				for strings.HasPrefix(heading, "*") {
120					heading = strings.TrimPrefix(heading, "*")
121				}
122				heading = strings.TrimSpace(heading)
123
124				state := ""
125				for _, s := range []string{"TODO", "DONE", "NEXT", "STRT", "WAIT", "CANX"} {
126					if strings.HasPrefix(heading, s+" ") {
127						state = s
128						heading = strings.TrimPrefix(heading, s+" ")
129						break
130					}
131				}
132				items = append(items, ScheduledItem{
133					Heading: strings.TrimSpace(heading),
134					State:   state,
135				})
136			}
137		}
138		prevLine = line
139	}
140	return items, scanner.Err()
141}
142
143// HasJiraKey checks if a Jira issue key already exists in the org file (as a non-DONE TODO).
144func HasJiraKey(orgFile, key string) bool {
145	return hasProperty(orgFile, "JIRA_KEY", key)
146}
147
148// HasGitHubIssue checks if a GitHub issue already exists in the org file (as a non-DONE TODO).
149func HasGitHubIssue(orgFile, repo string, number int) bool {
150	return hasPropertyPair(orgFile, "GH_REPO", repo, "GH_NUMBER", fmt.Sprintf("%d", number))
151}
152
153func hasProperty(orgFile, propName, propValue string) bool {
154	f, err := os.Open(orgFile)
155	if err != nil {
156		return false
157	}
158	defer f.Close()
159
160	needle := fmt.Sprintf(":%s: %s", propName, propValue)
161	scanner := bufio.NewScanner(f)
162	for scanner.Scan() {
163		if strings.Contains(scanner.Text(), needle) {
164			return true
165		}
166	}
167	return false
168}
169
170func hasPropertyPair(orgFile, prop1, val1, prop2, val2 string) bool {
171	f, err := os.Open(orgFile)
172	if err != nil {
173		return false
174	}
175	defer f.Close()
176
177	needle1 := fmt.Sprintf(":%s: %s", prop1, val1)
178	needle2 := fmt.Sprintf(":%s: %s", prop2, val2)
179	found1 := false
180	scanner := bufio.NewScanner(f)
181	for scanner.Scan() {
182		line := scanner.Text()
183		if strings.Contains(line, needle1) {
184			found1 = true
185		}
186		if found1 && strings.Contains(line, needle2) {
187			return true
188		}
189		// Reset if we leave a :PROPERTIES: block
190		if found1 && strings.TrimSpace(line) == ":END:" {
191			found1 = false
192		}
193	}
194	return false
195}
196
197// ScheduleJiraIssue creates an org TODO for a Jira issue under the given section.
198func ScheduleJiraIssue(orgFile, section, key, summary, url string, date time.Time) error {
199	dateStr := date.Format("2006-01-02 Mon")
200	createdStr := time.Now().Format("2006-01-02 Mon 15:04")
201
202	orgContent := fmt.Sprintf(
203		"\n** TODO [[%s][%s]] %s  :jira:\nSCHEDULED: <%s>\n:PROPERTIES:\n:JIRA_KEY: %s\n:URL: %s\n:CREATED: [%s]\n:END:\n",
204		url, key, summary, dateStr, key, url, createdStr,
205	)
206
207	return appendToSection(orgFile, section, orgContent)
208}
209
210// ScheduleGitHubIssue creates an org TODO for a GitHub issue under the given section.
211func ScheduleGitHubIssue(orgFile, section, repo string, number int, summary, url string, date time.Time) error {
212	dateStr := date.Format("2006-01-02 Mon")
213	createdStr := time.Now().Format("2006-01-02 Mon 15:04")
214	ref := fmt.Sprintf("%s#%d", repo, number)
215
216	orgContent := fmt.Sprintf(
217		"\n** TODO [[%s][%s]] %s  :github:\nSCHEDULED: <%s>\n:PROPERTIES:\n:GH_REPO: %s\n:GH_NUMBER: %d\n:URL: %s\n:CREATED: [%s]\n:END:\n",
218		url, ref, summary, dateStr, repo, number, url, createdStr,
219	)
220
221	return appendToSection(orgFile, section, orgContent)
222}
223
224func appendToSection(orgFile, section, content string) error {
225	escapedSection := strings.ReplaceAll(section, `"`, `\"`)
226	escapedContent := strings.ReplaceAll(content, `"`, `\"`)
227	escapedContent = strings.ReplaceAll(escapedContent, `\`, `\\`)
228
229	elisp := fmt.Sprintf(`
230      (with-current-buffer (find-file-noselect %q)
231        (goto-char (point-min))
232        (re-search-forward "^\\* %s")
233        (org-end-of-subtree t)
234        (insert %q)
235        (save-buffer)
236        "ok")`, orgFile, escapedSection, content)
237
238	cmd := exec.Command("emacsclient", "--eval", elisp)
239	var stderr bytes.Buffer
240	cmd.Stderr = &stderr
241	if err := cmd.Run(); err != nil {
242		return fmt.Errorf("emacsclient error: %s: %w", stderr.String(), err)
243	}
244	return nil
245}