Commit c8e2a8fd9140

Vincent Demeester <vincent@sbr.pm>
2026-01-14 11:49:24
feat(org): add Google Calendar to org-mode sync tool
- Enable one-way calendar sync for viewing events in org-agenda - Keep last 2 weeks of events to reduce clutter and stay focused - Integrate seamlessly with existing gcalcli authentication - Provide automation support via systemd timer or cron Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 2b2c813
Changed files (7)
dots
.config
emacs
pkgs
tools
dots/.config/emacs/init.el
@@ -23,6 +23,8 @@ share elsewhere, with Flat habits on iOS for example.")
 (defconst org-reading-list-file (expand-file-name "reading-list.org" org-directory)
   "`org-mode' file for list of things to read.
 Most likely these needs to be added to readwise reader or ditch.")
+(defconst org-calendar-file (expand-file-name "calendar.org" org-directory)
+  "`org-mode' calendar file, auto-generated from Google Calendar (read-only).")
 (defconst org-journelly-file (expand-file-name "Journelly.org" org-directory)
   "`org-mode' file for journalling.
 It is shared with iOS and replace the deprecated `org-journal-file' below.")
@@ -39,6 +41,7 @@ It is shared with iOS and replace the deprecated `org-journal-file' below.")
 (set-register ?e `(file . ,(locate-user-emacs-file "init.el")))
 (set-register ?i `(file . ,org-inbox-file))
 (set-register ?t `(file . ,org-todos-file))
+(set-register ?c `(file . ,org-calendar-file))
 (set-register ?j `(file . ,org-journal-file))
 (set-register ?o `(file . ,org-directory))
 (set-register ?n `(file . ,org-notes-directory))
@@ -1596,7 +1599,7 @@ minibuffer, even without explicitly focusing it."
   (org-priority-default 4)
   (org-list-demote-modify-bullet '(("+" . "-") ("-" . "+")))
   (org-agenda-file-regexp "^[a-zA-Z0-9-_]+.org$")
-  (org-agenda-files `(,org-inbox-file ,org-todos-file ,org-habits-file ,org-reading-list-file))
+  (org-agenda-files `(,org-inbox-file ,org-todos-file ,org-habits-file ,org-reading-list-file ,org-calendar-file))
   ;; (org-refile-targets '((org-agenda-files :maxlevel . 3)))
   (org-refile-targets (vde/org-refile-targets))
   (org-refile-use-outline-path 'file)
pkgs/default.nix
@@ -35,6 +35,7 @@ in
   music-playlist-dl = pkgs.callPackage ../tools/music-playlist-dl { };
   nix-flake-update = pkgs.callPackage ../tools/nix-flake-update { };
   nixpkgs-consolidate = pkgs.callPackage ../tools/nixpkgs-consolidate { };
+  gcal-to-org = pkgs.callPackage ../tools/gcal-to-org { };
   beets-lidarr-fields = pkgs.python3Packages.callPackage ./beets-lidarr-fields { };
   beets-filetote = pkgs.python3Packages.callPackage ./beets-filetote { };
 
tools/gcal-to-org/default.nix
@@ -0,0 +1,17 @@
+{ pkgs }:
+
+pkgs.buildGoModule {
+  pname = "gcal-to-org";
+  version = "0.1.0";
+
+  src = ./.;
+
+  vendorHash = null;
+
+  meta = with pkgs.lib; {
+    description = "Sync Google Calendar to org-mode file (one-way)";
+    homepage = "https://github.com/vdemeester/home";
+    license = licenses.asl20;
+    maintainers = [ ];
+  };
+}
tools/gcal-to-org/go.mod
@@ -0,0 +1,3 @@
+module github.com/vdemeester/home/tools/gcal-to-org
+
+go 1.23
tools/gcal-to-org/main.go
@@ -0,0 +1,355 @@
+package main
+
+import (
+	"bytes"
+	"encoding/csv"
+	"flag"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"sort"
+	"strings"
+	"time"
+)
+
+const (
+	defaultDaysBack    = 14
+	defaultDaysForward = 28
+)
+
+var (
+	defaultCalendars = []string{
+		"Vincent Demeester (personal)",
+		"vdemeest@redhat.com",
+	}
+)
+
+// Config holds the application configuration
+type Config struct {
+	Calendars   []string
+	OutputFile  string
+	DaysBack    int
+	DaysForward int
+}
+
+// Event represents a calendar event from gcalcli
+type Event struct {
+	StartDate string
+	StartTime string
+	EndDate   string
+	EndTime   string
+	Title     string
+}
+
+// calendarsFlag implements flag.Value for string slice
+type calendarsFlag []string
+
+func (c *calendarsFlag) String() string {
+	return strings.Join(*c, ",")
+}
+
+func (c *calendarsFlag) Set(value string) error {
+	*c = append(*c, value)
+	return nil
+}
+
+func main() {
+	// Parse command-line flags
+	var calendars calendarsFlag
+	var outputFile string
+	var daysBack, daysForward int
+
+	homeDir, err := os.UserHomeDir()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
+		os.Exit(1)
+	}
+	defaultOutputFile := filepath.Join(homeDir, "desktop", "org", "calendar.org")
+
+	flag.Var(&calendars, "calendar", "Calendar name to sync (can be specified multiple times)")
+	flag.StringVar(&outputFile, "output", defaultOutputFile, "Output org file path")
+	flag.IntVar(&daysBack, "days-back", defaultDaysBack, "Number of days to look back")
+	flag.IntVar(&daysForward, "days-forward", defaultDaysForward, "Number of days to look forward")
+	flag.Parse()
+
+	// Use default calendars if none specified
+	if len(calendars) == 0 {
+		calendars = defaultCalendars
+	}
+
+	config := &Config{
+		Calendars:   calendars,
+		OutputFile:  outputFile,
+		DaysBack:    daysBack,
+		DaysForward: daysForward,
+	}
+
+	if err := run(config); err != nil {
+		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+		os.Exit(1)
+	}
+}
+
+func run(config *Config) error {
+	// Calculate date range
+	today := time.Now()
+	startDate := today.AddDate(0, 0, -config.DaysBack).Format("2006-01-02")
+	endDate := today.AddDate(0, 0, config.DaysForward).Format("2006-01-02")
+
+	fmt.Printf("Fetching calendar events from %s to %s...\n", startDate, endDate)
+	fmt.Printf("Calendars: %s\n", strings.Join(config.Calendars, ", "))
+
+	// Fetch events from gcalcli
+	events, err := fetchCalendarData(config.Calendars, startDate, endDate)
+	if err != nil {
+		return fmt.Errorf("failed to fetch calendar data: %w", err)
+	}
+
+	// Generate org file
+	if err := generateOrgFile(events, config.OutputFile, startDate, endDate); err != nil {
+		return fmt.Errorf("failed to generate org file: %w", err)
+	}
+
+	fmt.Printf("Calendar synced to %s\n", config.OutputFile)
+	fmt.Printf("Total events: %d\n", len(events))
+
+	return nil
+}
+
+func fetchCalendarData(calendars []string, startDate, endDate string) ([]Event, error) {
+	// Build gcalcli command
+	args := []string{"agenda", "--tsv"}
+
+	// Add calendar filters
+	for _, cal := range calendars {
+		args = append(args, "--calendar", cal)
+	}
+
+	// Add date range
+	args = append(args, startDate, endDate)
+
+	// Execute gcalcli
+	cmd := exec.Command("gcalcli", args...)
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+
+	if err := cmd.Run(); err != nil {
+		return nil, fmt.Errorf("gcalcli command failed: %w\nstderr: %s", err, stderr.String())
+	}
+
+	// Parse TSV output
+	reader := csv.NewReader(bytes.NewReader(stdout.Bytes()))
+	reader.Comma = '\t'
+	reader.FieldsPerRecord = -1 // Allow variable number of fields
+	reader.LazyQuotes = true    // Allow bare quotes in fields (e.g., event titles with ")
+
+	records, err := reader.ReadAll()
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse TSV output: %w", err)
+	}
+
+	if len(records) == 0 {
+		return []Event{}, nil
+	}
+
+	// First row is header
+	header := records[0]
+	var startDateIdx, startTimeIdx, endDateIdx, endTimeIdx, titleIdx int
+	for i, h := range header {
+		switch h {
+		case "start_date":
+			startDateIdx = i
+		case "start_time":
+			startTimeIdx = i
+		case "end_date":
+			endDateIdx = i
+		case "end_time":
+			endTimeIdx = i
+		case "title":
+			titleIdx = i
+		}
+	}
+
+	// Parse events
+	events := make([]Event, 0, len(records)-1)
+	for _, record := range records[1:] {
+		if len(record) == 0 {
+			continue
+		}
+
+		event := Event{
+			StartDate: getField(record, startDateIdx),
+			StartTime: getField(record, startTimeIdx),
+			EndDate:   getField(record, endDateIdx),
+			EndTime:   getField(record, endTimeIdx),
+			Title:     getField(record, titleIdx),
+		}
+
+		events = append(events, event)
+	}
+
+	return events, nil
+}
+
+func getField(record []string, idx int) string {
+	if idx >= len(record) {
+		return ""
+	}
+	return record[idx]
+}
+
+func generateOrgFile(events []Event, outputFile, startDate, endDate string) error {
+	// Group events by date
+	eventsByDate := make(map[string][]Event)
+	for _, event := range events {
+		if event.StartDate == "" {
+			continue
+		}
+		eventsByDate[event.StartDate] = append(eventsByDate[event.StartDate], event)
+	}
+
+	// Get sorted dates
+	dates := make([]string, 0, len(eventsByDate))
+	for date := range eventsByDate {
+		dates = append(dates, date)
+	}
+	sort.Strings(dates)
+
+	// Build org file content
+	var buf bytes.Buffer
+	fmt.Fprintf(&buf, `#+TITLE: Google Calendar Sync
+#+DESCRIPTION: Auto-generated from Google Calendar (one-way sync)
+#+STARTUP: overview
+#+CATEGORY: calendar
+#+FILETAGS: :calendar:gcal:
+
+This file is automatically generated from Google Calendar.
+DO NOT EDIT MANUALLY - changes will be overwritten.
+
+Sync range: %s to %s
+Last updated: %s
+
+`, startDate, endDate, time.Now().Format("2006-01-02 15:04:05"))
+
+	// Add events sorted by date
+	for _, date := range dates {
+		dateObj, err := time.Parse("2006-01-02", date)
+		if err != nil {
+			continue
+		}
+
+		fmt.Fprintf(&buf, "\n* %s\n\n", dateObj.Format("2006-01-02 Monday"))
+
+		// Sort events by time (all-day events first - empty start_time)
+		dayEvents := eventsByDate[date]
+		sort.Slice(dayEvents, func(i, j int) bool {
+			return dayEvents[i].StartTime < dayEvents[j].StartTime
+		})
+
+		for _, event := range dayEvents {
+			fmt.Fprint(&buf, eventToOrgMode(event))
+			fmt.Fprintln(&buf)
+		}
+	}
+
+	// Create output directory if needed
+	if err := os.MkdirAll(filepath.Dir(outputFile), 0755); err != nil {
+		return fmt.Errorf("failed to create output directory: %w", err)
+	}
+
+	// Write to file
+	if err := os.WriteFile(outputFile, buf.Bytes(), 0644); err != nil {
+		return fmt.Errorf("failed to write org file: %w", err)
+	}
+
+	return nil
+}
+
+func eventToOrgMode(event Event) string {
+	var buf bytes.Buffer
+
+	title := event.Title
+	if title == "" {
+		title = "Untitled"
+	}
+
+	fmt.Fprintf(&buf, "* %s\n", title)
+
+	// Build timestamp
+	timestamp := formatTimestamp(event)
+	if timestamp != "" {
+		fmt.Fprintf(&buf, "%s\n", timestamp)
+	}
+
+	return buf.String()
+}
+
+func formatTimestamp(event Event) string {
+	// Parse start date/time
+	if event.StartDate == "" {
+		return ""
+	}
+
+	startDate, err := time.Parse("2006-01-02", event.StartDate)
+	if err != nil {
+		return ""
+	}
+
+	if event.StartTime == "" {
+		// All-day event
+		if event.EndDate == "" || event.EndDate == event.StartDate {
+			// Single day
+			return fmt.Sprintf("<%s>", startDate.Format("2006-01-02 Mon"))
+		}
+
+		// Multi-day all-day event
+		// gcalcli end_date is exclusive, so subtract 1 day
+		endDate, err := time.Parse("2006-01-02", event.EndDate)
+		if err != nil {
+			return fmt.Sprintf("<%s>", startDate.Format("2006-01-02 Mon"))
+		}
+		endDate = endDate.AddDate(0, 0, -1)
+
+		// Check if it's actually a single-day event after adjustment
+		if endDate.Equal(startDate) {
+			return fmt.Sprintf("<%s>", startDate.Format("2006-01-02 Mon"))
+		}
+
+		return fmt.Sprintf("<%s>--<%s>",
+			startDate.Format("2006-01-02 Mon"),
+			endDate.Format("2006-01-02 Mon"))
+	}
+
+	// Timed event
+	startDateTime := fmt.Sprintf("%s %s", event.StartDate, event.StartTime)
+	startDT, err := time.Parse("2006-01-02 15:04", startDateTime)
+	if err != nil {
+		return fmt.Sprintf("<%s>", startDate.Format("2006-01-02 Mon"))
+	}
+
+	if event.EndTime == "" {
+		// Single timestamp
+		return fmt.Sprintf("<%s>", startDT.Format("2006-01-02 Mon 15:04"))
+	}
+
+	endDateTime := fmt.Sprintf("%s %s", event.EndDate, event.EndTime)
+	endDT, err := time.Parse("2006-01-02 15:04", endDateTime)
+	if err != nil {
+		return fmt.Sprintf("<%s>", startDT.Format("2006-01-02 Mon 15:04"))
+	}
+
+	// Check if same day
+	if startDT.Year() == endDT.Year() && startDT.YearDay() == endDT.YearDay() {
+		// Same-day event with time range
+		return fmt.Sprintf("<%s-%s>",
+			startDT.Format("2006-01-02 Mon 15:04"),
+			endDT.Format("15:04"))
+	}
+
+	// Multi-day event with times
+	return fmt.Sprintf("<%s>--<%s>",
+		startDT.Format("2006-01-02 Mon 15:04"),
+		endDT.Format("2006-01-02 Mon 15:04"))
+}
tools/gcal-to-org/README.md
@@ -0,0 +1,198 @@
+# gcal-to-org
+
+One-way sync from Google Calendar to org-mode file.
+
+## Features
+
+- **One-way sync**: Google Calendar → org-mode only (read-only, no modifications to Google Calendar)
+- **Separate file**: Generates `~/desktop/org/calendar.org` which is easily regenerated
+- **Automatic cleanup**: Keeps only events from 2 weeks ago onward (configurable)
+- **Works with gcalcli**: Uses your existing gcalcli setup and authentication
+
+## Usage
+
+### Basic usage
+
+```bash
+# Sync default calendars to default file
+gcal-to-org
+
+# This generates ~/desktop/org/calendar.org with:
+# - Events from 2 weeks ago to 4 weeks forward
+# - Personal and work calendars
+```
+
+### Custom configuration
+
+```bash
+# Sync specific calendars
+gcal-to-org --calendar "My Calendar" --calendar "Work Calendar"
+
+# Custom date range
+gcal-to-org --days-back 7 --days-forward 14
+
+# Custom output file
+gcal-to-org --output ~/my-calendar.org
+
+# Combine options
+gcal-to-org \
+  --calendar "Vincent Demeester (personal)" \
+  --days-back 14 \
+  --days-forward 28 \
+  --output ~/desktop/org/calendar.org
+```
+
+## Configuration
+
+### Default settings
+
+- **Calendars**:
+  - Vincent Demeester (personal)
+  - vdemeest@redhat.com
+- **Output file**: `~/desktop/org/calendar.org`
+- **Date range**: 14 days back to 28 days forward
+
+### Changing defaults
+
+Edit the constants in `main.go`:
+
+```go
+var (
+    defaultCalendars = []string{
+        "Vincent Demeester (personal)",
+        "vdemeest@redhat.com",
+    }
+)
+
+const (
+    defaultDaysBack    = 14
+    defaultDaysForward = 28
+)
+```
+
+## Automation
+
+### Systemd timer (recommended)
+
+Create `~/.config/systemd/user/gcal-sync.service`:
+
+```ini
+[Unit]
+Description=Sync Google Calendar to org-mode
+
+[Service]
+Type=oneshot
+ExecStart=%h/.nix-profile/bin/gcal-to-org
+```
+
+Create `~/.config/systemd/user/gcal-sync.timer`:
+
+```ini
+[Unit]
+Description=Sync Google Calendar every hour
+
+[Timer]
+OnCalendar=hourly
+Persistent=true
+
+[Install]
+WantedBy=timers.target
+```
+
+Enable and start:
+
+```bash
+systemctl --user enable --now gcal-sync.timer
+systemctl --user status gcal-sync.timer
+```
+
+### Cron
+
+```bash
+# Add to crontab -e
+0 * * * * /path/to/gcal-to-org
+```
+
+## Output format
+
+The generated org file includes:
+
+```org
+#+TITLE: Google Calendar Sync
+#+DESCRIPTION: Auto-generated from Google Calendar (one-way sync)
+#+STARTUP: overview
+#+CATEGORY: calendar
+#+FILETAGS: :calendar:gcal:
+
+This file is automatically generated from Google Calendar.
+DO NOT EDIT MANUALLY - changes will be overwritten.
+
+* 2026-01-14 Wednesday
+
+* Meeting with team
+<2026-01-14 Wed 09:00-10:00>
+
+* All-day event
+<2026-01-14 Wed>
+
+* Multi-day conference
+<2026-01-14 Wed>--<2026-01-16 Fri>
+```
+
+## Building
+
+```bash
+# Build with Nix
+nix build .#gcal-to-org
+
+# Or build with Go
+cd tools/gcal-to-org
+go build
+```
+
+## Troubleshooting
+
+### Authentication errors
+
+Make sure `gcalcli` is set up and authenticated:
+
+```bash
+gcalcli list
+```
+
+If this fails, run:
+
+```bash
+gcalcli init
+```
+
+### Missing calendars
+
+List available calendars:
+
+```bash
+gcalcli list
+```
+
+Then use the exact calendar names with `--calendar` flag.
+
+### Parsing errors
+
+If you encounter TSV parsing errors, this is likely due to special characters in event titles. The tool uses `LazyQuotes` mode to handle most cases, but if issues persist, try filtering specific calendars with `--calendar`.
+
+## Integration with org-mode
+
+To use the calendar in your agenda views, add to your Emacs config:
+
+```elisp
+;; Add calendar.org to agenda files
+(add-to-list 'org-agenda-files "~/desktop/org/calendar.org")
+
+;; Optional: exclude from refile targets (read-only)
+(setq org-refile-targets
+      '((org-agenda-files :maxlevel . 3)))
+```
+
+## License
+
+Apache 2.0
tools/shpool-ssh-wrapper/default.nix
@@ -70,8 +70,14 @@ pkgs.writeScriptBin "shpool-ssh-wrapper" ''
           run_with_command "$SESSION_NAME" "${claude-vertex}" "$WORK_DIR"
           ;;
       *)
-          # Default: just attach to session with default shell
-          exec ${pkgs.shpool}/bin/shpool attach -f "$SESSION_NAME"
+          # If work directory specified, cd into it before attaching
+          if [ -n "$WORK_DIR" ]; then
+              # Start shell and cd into directory
+              exec ${pkgs.shpool}/bin/shpool attach -f -c "${pkgs.zsh}/bin/zsh -c 'cd $WORK_DIR && exec ${pkgs.zsh}/bin/zsh'" "$SESSION_NAME"
+          else
+              # Default: just attach to session with default shell
+              exec ${pkgs.shpool}/bin/shpool attach -f "$SESSION_NAME"
+          fi
           ;;
   esac
 ''