main
1package main
2
3import (
4 "bytes"
5 "encoding/csv"
6 "flag"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "sort"
12 "strings"
13 "time"
14)
15
16const (
17 defaultDaysBack = 14
18 defaultDaysForward = 28
19)
20
21var (
22 defaultCalendars = []string{
23 "Vincent Demeester (personal)",
24 "vdemeest@redhat.com",
25 }
26)
27
28// Config holds the application configuration
29type Config struct {
30 Calendars []string
31 OutputFile string
32 DaysBack int
33 DaysForward int
34}
35
36// Event represents a calendar event from gcalcli
37type Event struct {
38 StartDate string
39 StartTime string
40 EndDate string
41 EndTime string
42 Title string
43 Calendar string
44}
45
46// calendarsFlag implements flag.Value for string slice
47type calendarsFlag []string
48
49func (c *calendarsFlag) String() string {
50 return strings.Join(*c, ",")
51}
52
53func (c *calendarsFlag) Set(value string) error {
54 *c = append(*c, value)
55 return nil
56}
57
58func main() {
59 // Parse command-line flags
60 var calendars calendarsFlag
61 var outputFile string
62 var daysBack, daysForward int
63
64 homeDir, err := os.UserHomeDir()
65 if err != nil {
66 fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
67 os.Exit(1)
68 }
69 defaultOutputFile := filepath.Join(homeDir, "desktop", "org", "calendar.org")
70
71 flag.Var(&calendars, "calendar", "Calendar name to sync (can be specified multiple times)")
72 flag.StringVar(&outputFile, "output", defaultOutputFile, "Output org file path")
73 flag.IntVar(&daysBack, "days-back", defaultDaysBack, "Number of days to look back")
74 flag.IntVar(&daysForward, "days-forward", defaultDaysForward, "Number of days to look forward")
75 flag.Parse()
76
77 // Use default calendars if none specified
78 if len(calendars) == 0 {
79 calendars = defaultCalendars
80 }
81
82 config := &Config{
83 Calendars: calendars,
84 OutputFile: outputFile,
85 DaysBack: daysBack,
86 DaysForward: daysForward,
87 }
88
89 if err := run(config); err != nil {
90 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
91 os.Exit(1)
92 }
93}
94
95func run(config *Config) error {
96 // Calculate date range
97 today := time.Now()
98 startDate := today.AddDate(0, 0, -config.DaysBack).Format("2006-01-02")
99 endDate := today.AddDate(0, 0, config.DaysForward).Format("2006-01-02")
100
101 fmt.Printf("Fetching calendar events from %s to %s...\n", startDate, endDate)
102 fmt.Printf("Calendars: %s\n", strings.Join(config.Calendars, ", "))
103
104 // Fetch events from gcalcli
105 events, err := fetchCalendarData(config.Calendars, startDate, endDate)
106 if err != nil {
107 return fmt.Errorf("failed to fetch calendar data: %w", err)
108 }
109
110 // Generate org file
111 if err := generateOrgFile(events, config.OutputFile, startDate, endDate); err != nil {
112 return fmt.Errorf("failed to generate org file: %w", err)
113 }
114
115 fmt.Printf("Calendar synced to %s\n", config.OutputFile)
116 fmt.Printf("Total events: %d\n", len(events))
117
118 return nil
119}
120
121func fetchCalendarData(calendars []string, startDate, endDate string) ([]Event, error) {
122 // Build gcalcli command
123 args := []string{"agenda", "--tsv", "--details", "calendar", "--nodeclined"}
124
125 // Add calendar filters
126 for _, cal := range calendars {
127 args = append(args, "--calendar", cal)
128 }
129
130 // Add date range
131 args = append(args, startDate, endDate)
132
133 // Execute gcalcli
134 cmd := exec.Command("gcalcli", args...)
135 var stdout, stderr bytes.Buffer
136 cmd.Stdout = &stdout
137 cmd.Stderr = &stderr
138
139 if err := cmd.Run(); err != nil {
140 return nil, fmt.Errorf("gcalcli command failed: %w\nstderr: %s", err, stderr.String())
141 }
142
143 // Parse TSV output
144 reader := csv.NewReader(bytes.NewReader(stdout.Bytes()))
145 reader.Comma = '\t'
146 reader.FieldsPerRecord = -1 // Allow variable number of fields
147 reader.LazyQuotes = true // Allow bare quotes in fields (e.g., event titles with ")
148
149 records, err := reader.ReadAll()
150 if err != nil {
151 return nil, fmt.Errorf("failed to parse TSV output: %w", err)
152 }
153
154 if len(records) == 0 {
155 return []Event{}, nil
156 }
157
158 // First row is header
159 header := records[0]
160 var startDateIdx, startTimeIdx, endDateIdx, endTimeIdx, titleIdx, calendarIdx int
161 for i, h := range header {
162 switch h {
163 case "start_date":
164 startDateIdx = i
165 case "start_time":
166 startTimeIdx = i
167 case "end_date":
168 endDateIdx = i
169 case "end_time":
170 endTimeIdx = i
171 case "title":
172 titleIdx = i
173 case "calendar":
174 calendarIdx = i
175 }
176 }
177
178 // Parse events
179 events := make([]Event, 0, len(records)-1)
180 for _, record := range records[1:] {
181 if len(record) == 0 {
182 continue
183 }
184
185 event := Event{
186 StartDate: getField(record, startDateIdx),
187 StartTime: getField(record, startTimeIdx),
188 EndDate: getField(record, endDateIdx),
189 EndTime: getField(record, endTimeIdx),
190 Title: getField(record, titleIdx),
191 Calendar: getField(record, calendarIdx),
192 }
193
194 events = append(events, event)
195 }
196
197 return events, nil
198}
199
200func getField(record []string, idx int) string {
201 if idx >= len(record) {
202 return ""
203 }
204 return record[idx]
205}
206
207// categorizeCalendar returns "work" or "personal" based on calendar name
208func categorizeCalendar(calendarName string) string {
209 lowerName := strings.ToLower(calendarName)
210
211 // Work calendar indicators
212 workKeywords := []string{"redhat", "work", "vdemeest@redhat"}
213 for _, keyword := range workKeywords {
214 if strings.Contains(lowerName, keyword) {
215 return "work"
216 }
217 }
218
219 // Default to personal
220 return "personal"
221}
222
223func generateOrgFile(events []Event, outputFile, startDate, endDate string) error {
224 // Group events by date
225 eventsByDate := make(map[string][]Event)
226 for _, event := range events {
227 if event.StartDate == "" {
228 continue
229 }
230 eventsByDate[event.StartDate] = append(eventsByDate[event.StartDate], event)
231 }
232
233 // Get sorted dates
234 dates := make([]string, 0, len(eventsByDate))
235 for date := range eventsByDate {
236 dates = append(dates, date)
237 }
238 sort.Strings(dates)
239
240 // Build org file content
241 var buf bytes.Buffer
242 fmt.Fprintf(&buf, `#+TITLE: Google Calendar Sync
243#+DESCRIPTION: Auto-generated from Google Calendar (one-way sync)
244#+STARTUP: overview
245#+CATEGORY: calendar
246#+FILETAGS: :calendar:gcal:
247
248This file is automatically generated from Google Calendar.
249DO NOT EDIT MANUALLY - changes will be overwritten.
250
251Sync range: %s to %s
252Last updated: %s
253
254`, startDate, endDate, time.Now().Format("2006-01-02 15:04:05"))
255
256 // Add events sorted by date
257 for _, date := range dates {
258 dateObj, err := time.Parse("2006-01-02", date)
259 if err != nil {
260 continue
261 }
262
263 fmt.Fprintf(&buf, "\n* %s\n\n", dateObj.Format("2006-01-02 Monday"))
264
265 // Sort events by time (all-day events first - empty start_time)
266 dayEvents := eventsByDate[date]
267 sort.Slice(dayEvents, func(i, j int) bool {
268 return dayEvents[i].StartTime < dayEvents[j].StartTime
269 })
270
271 for _, event := range dayEvents {
272 fmt.Fprint(&buf, eventToOrgMode(event))
273 fmt.Fprintln(&buf)
274 }
275 }
276
277 // Create output directory if needed
278 if err := os.MkdirAll(filepath.Dir(outputFile), 0755); err != nil {
279 return fmt.Errorf("failed to create output directory: %w", err)
280 }
281
282 // Write to file
283 if err := os.WriteFile(outputFile, buf.Bytes(), 0644); err != nil {
284 return fmt.Errorf("failed to write org file: %w", err)
285 }
286
287 return nil
288}
289
290func eventToOrgMode(event Event) string {
291 var buf bytes.Buffer
292
293 title := event.Title
294 if title == "" {
295 title = "Untitled"
296 }
297
298 fmt.Fprintf(&buf, "* %s\n", title)
299
300 // Add PROPERTIES drawer with CATEGORY
301 if event.Calendar != "" {
302 category := categorizeCalendar(event.Calendar)
303 fmt.Fprintf(&buf, ":PROPERTIES:\n")
304 fmt.Fprintf(&buf, ":CATEGORY: %s\n", category)
305 fmt.Fprintf(&buf, ":END:\n")
306 }
307
308 // Build timestamp
309 timestamp := formatTimestamp(event)
310 if timestamp != "" {
311 fmt.Fprintf(&buf, "%s\n", timestamp)
312 }
313
314 return buf.String()
315}
316
317func formatTimestamp(event Event) string {
318 // Parse start date/time
319 if event.StartDate == "" {
320 return ""
321 }
322
323 startDate, err := time.Parse("2006-01-02", event.StartDate)
324 if err != nil {
325 return ""
326 }
327
328 if event.StartTime == "" {
329 // All-day event
330 if event.EndDate == "" || event.EndDate == event.StartDate {
331 // Single day
332 return fmt.Sprintf("<%s>", startDate.Format("2006-01-02 Mon"))
333 }
334
335 // Multi-day all-day event
336 // gcalcli end_date is exclusive, so subtract 1 day
337 endDate, err := time.Parse("2006-01-02", event.EndDate)
338 if err != nil {
339 return fmt.Sprintf("<%s>", startDate.Format("2006-01-02 Mon"))
340 }
341 endDate = endDate.AddDate(0, 0, -1)
342
343 // Check if it's actually a single-day event after adjustment
344 if endDate.Equal(startDate) {
345 return fmt.Sprintf("<%s>", startDate.Format("2006-01-02 Mon"))
346 }
347
348 return fmt.Sprintf("<%s>--<%s>",
349 startDate.Format("2006-01-02 Mon"),
350 endDate.Format("2006-01-02 Mon"))
351 }
352
353 // Timed event
354 startDateTime := fmt.Sprintf("%s %s", event.StartDate, event.StartTime)
355 startDT, err := time.Parse("2006-01-02 15:04", startDateTime)
356 if err != nil {
357 return fmt.Sprintf("<%s>", startDate.Format("2006-01-02 Mon"))
358 }
359
360 if event.EndTime == "" {
361 // Single timestamp
362 return fmt.Sprintf("<%s>", startDT.Format("2006-01-02 Mon 15:04"))
363 }
364
365 endDateTime := fmt.Sprintf("%s %s", event.EndDate, event.EndTime)
366 endDT, err := time.Parse("2006-01-02 15:04", endDateTime)
367 if err != nil {
368 return fmt.Sprintf("<%s>", startDT.Format("2006-01-02 Mon 15:04"))
369 }
370
371 // Check if same day
372 if startDT.Year() == endDT.Year() && startDT.YearDay() == endDT.YearDay() {
373 // Same-day event with time range
374 return fmt.Sprintf("<%s-%s>",
375 startDT.Format("2006-01-02 Mon 15:04"),
376 endDT.Format("15:04"))
377 }
378
379 // Multi-day event with times
380 return fmt.Sprintf("<%s>--<%s>",
381 startDT.Format("2006-01-02 Mon 15:04"),
382 endDT.Format("2006-01-02 Mon 15:04"))
383}