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 "strings"
11 "time"
12)
13
14// ScheduledItem represents an org TODO scheduled for a specific day.
15type ScheduledItem struct {
16 Heading string
17 State string // TODO, DONE, STRT, etc.
18}
19
20// TodayItems returns org items scheduled for today from the given file.
21func TodayItems(orgFile string) ([]ScheduledItem, error) {
22 today := time.Now().Format("2006-01-02")
23 return itemsForDate(orgFile, today)
24}
25
26func itemsForDate(orgFile, date string) ([]ScheduledItem, error) {
27 f, err := os.Open(orgFile)
28 if err != nil {
29 return nil, err
30 }
31 defer f.Close()
32
33 var items []ScheduledItem
34 scanner := bufio.NewScanner(f)
35 var prevLine string
36 for scanner.Scan() {
37 line := scanner.Text()
38 if strings.Contains(line, fmt.Sprintf("SCHEDULED: <%s", date)) ||
39 strings.Contains(line, fmt.Sprintf("DEADLINE: <%s", date)) {
40 // Previous line should be the heading
41 if strings.HasPrefix(strings.TrimSpace(prevLine), "**") {
42 heading := strings.TrimSpace(prevLine)
43 // Strip leading stars
44 for strings.HasPrefix(heading, "*") {
45 heading = strings.TrimPrefix(heading, "*")
46 }
47 heading = strings.TrimSpace(heading)
48
49 state := ""
50 for _, s := range []string{"TODO", "DONE", "NEXT", "STRT", "WAIT", "CANX"} {
51 if strings.HasPrefix(heading, s+" ") {
52 state = s
53 heading = strings.TrimPrefix(heading, s+" ")
54 break
55 }
56 }
57 items = append(items, ScheduledItem{
58 Heading: strings.TrimSpace(heading),
59 State: state,
60 })
61 }
62 }
63 prevLine = line
64 }
65 return items, scanner.Err()
66}
67
68// HasJiraKey checks if a Jira issue key already exists in the org file (as a non-DONE TODO).
69func HasJiraKey(orgFile, key string) bool {
70 return hasProperty(orgFile, "JIRA_KEY", key)
71}
72
73// HasGitHubIssue checks if a GitHub issue already exists in the org file (as a non-DONE TODO).
74func HasGitHubIssue(orgFile, repo string, number int) bool {
75 return hasPropertyPair(orgFile, "GH_REPO", repo, "GH_NUMBER", fmt.Sprintf("%d", number))
76}
77
78func hasProperty(orgFile, propName, propValue string) bool {
79 f, err := os.Open(orgFile)
80 if err != nil {
81 return false
82 }
83 defer f.Close()
84
85 needle := fmt.Sprintf(":%s: %s", propName, propValue)
86 scanner := bufio.NewScanner(f)
87 for scanner.Scan() {
88 if strings.Contains(scanner.Text(), needle) {
89 return true
90 }
91 }
92 return false
93}
94
95func hasPropertyPair(orgFile, prop1, val1, prop2, val2 string) bool {
96 f, err := os.Open(orgFile)
97 if err != nil {
98 return false
99 }
100 defer f.Close()
101
102 needle1 := fmt.Sprintf(":%s: %s", prop1, val1)
103 needle2 := fmt.Sprintf(":%s: %s", prop2, val2)
104 found1 := false
105 scanner := bufio.NewScanner(f)
106 for scanner.Scan() {
107 line := scanner.Text()
108 if strings.Contains(line, needle1) {
109 found1 = true
110 }
111 if found1 && strings.Contains(line, needle2) {
112 return true
113 }
114 // Reset if we leave a :PROPERTIES: block
115 if found1 && strings.TrimSpace(line) == ":END:" {
116 found1 = false
117 }
118 }
119 return false
120}
121
122// ScheduleJiraIssue creates an org TODO for a Jira issue under the given section.
123func ScheduleJiraIssue(orgFile, section, key, summary, url string, date time.Time) error {
124 dateStr := date.Format("2006-01-02 Mon")
125 createdStr := time.Now().Format("2006-01-02 Mon 15:04")
126
127 orgContent := fmt.Sprintf(
128 "\n** TODO [[%s][%s]] %s :jira:\nSCHEDULED: <%s>\n:PROPERTIES:\n:JIRA_KEY: %s\n:URL: %s\n:CREATED: [%s]\n:END:\n",
129 url, key, summary, dateStr, key, url, createdStr,
130 )
131
132 return appendToSection(orgFile, section, orgContent)
133}
134
135// ScheduleGitHubIssue creates an org TODO for a GitHub issue under the given section.
136func ScheduleGitHubIssue(orgFile, section, repo string, number int, summary, url string, date time.Time) error {
137 dateStr := date.Format("2006-01-02 Mon")
138 createdStr := time.Now().Format("2006-01-02 Mon 15:04")
139 ref := fmt.Sprintf("%s#%d", repo, number)
140
141 orgContent := fmt.Sprintf(
142 "\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",
143 url, ref, summary, dateStr, repo, number, url, createdStr,
144 )
145
146 return appendToSection(orgFile, section, orgContent)
147}
148
149func appendToSection(orgFile, section, content string) error {
150 escapedSection := strings.ReplaceAll(section, `"`, `\"`)
151 escapedContent := strings.ReplaceAll(content, `"`, `\"`)
152 escapedContent = strings.ReplaceAll(escapedContent, `\`, `\\`)
153
154 elisp := fmt.Sprintf(`
155 (with-current-buffer (find-file-noselect %q)
156 (goto-char (point-min))
157 (re-search-forward "^\\* %s")
158 (org-end-of-subtree t)
159 (insert %q)
160 (save-buffer)
161 "ok")`, orgFile, escapedSection, content)
162
163 cmd := exec.Command("emacsclient", "--eval", elisp)
164 var stderr bytes.Buffer
165 cmd.Stderr = &stderr
166 if err := cmd.Run(); err != nil {
167 return fmt.Errorf("emacsclient error: %s: %w", stderr.String(), err)
168 }
169 return nil
170}