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}