Commit c8e2a8fd9140
Changed files (7)
dots
.config
emacs
pkgs
tools
gcal-to-org
shpool-ssh-wrapper
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
''