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}