Skip to content

Instantly share code, notes, and snippets.

@inge4pres
Created November 12, 2025 14:57
Show Gist options
  • Select an option

  • Save inge4pres/63fabb89f03482132d5d928d693bdf3d to your computer and use it in GitHub Desktop.

Select an option

Save inge4pres/63fabb89f03482132d5d928d693bdf3d to your computer and use it in GitHub Desktop.
Google Calendar Batch delete events

Google Calendar Cleaner

A Go tool to delete spurious entries in Google Calendar created by mistake when importing iCalendar files.

Prerequisites

  • Go 1.25.0 or later
  • Google Calendar API OAuth2 credentials

Setup

1. Create OAuth2 Credentials

  1. Go to the Google Cloud Console
  2. Create a new project or select an existing one
  3. Enable the Google Calendar API for your project:
    • Go to APIs & ServicesLibrary
    • Search for "Google Calendar API"
    • Click Enable
  4. Create OAuth2 credentials:
    • Go to APIs & ServicesCredentials
    • Click Create CredentialsOAuth client ID
    • Choose Desktop app as the application type
    • Give it a name (e.g., "gcal-cleaner")
    • Click Create
  5. Important: Configure the redirect URI:
    • Click on your newly created OAuth client to edit it
    • Under Authorized redirect URIs, add: http://localhost:8080/callback
    • Click Save
  6. Download the credentials JSON file (click the download icon next to your OAuth client)

2. Set Environment Variable

Export your OAuth2 credentials as an environment variable:

export GOOGLE_OAUTH_CREDENTIALS=$(cat /path/to/credentials.json)

Or set it inline when running the command:

GOOGLE_OAUTH_CREDENTIALS=$(cat /path/to/credentials.json) ./gcal-cleaner -query "search term"

Installation

go build -o gcal-cleaner

Usage

First Run

On the first run, the program will automatically open your browser for authorization:

./gcal-cleaner -query "search term"

The program will:

  1. Automatically open your browser to the Google authorization page
  2. Start a local server on http://localhost:8080 to receive the callback
  3. Wait for you to authorize the application

In your browser:

  1. Sign in with your Google account
  2. Grant access to the application
  3. You'll see "Authorization Successful!" in the browser
  4. The browser will automatically send the authorization code to the program

The authorization token will be saved to token.json for future use.

Note: If the browser doesn't open automatically, copy the URL from the terminal and paste it into your browser manually.

Subsequent Runs

After the first authorization, you can run the program normally:

./gcal-cleaner -query "search term" [options]

Options

  • -query (required): Search query to filter events by summary or description
  • -calendar: Calendar ID (default: "primary")
  • -time-min: Minimum time for events (RFC3339 format, e.g., 2024-01-01T00:00:00Z)
  • -time-max: Maximum time for events (RFC3339 format, e.g., 2024-12-31T23:59:59Z)

Examples

Search for events with "imported" in the summary or description:

./gcal-cleaner -query "imported"

Search for events within a specific time range:

./gcal-cleaner -query "duplicate" -time-min "2024-01-01T00:00:00Z" -time-max "2024-12-31T23:59:59Z"

Search in a specific calendar:

./gcal-cleaner -query "test" -calendar "[email protected]"

How It Works

  1. The tool authenticates with the Google Calendar API using OAuth2 credentials
  2. On first run, it will open a browser for you to authorize the application
  3. The authorization token is cached in token.json for future use
  4. It searches for events matching your query in the specified calendar
  5. It displays all matching events with their details (summary, description, start time, ID)
  6. It prompts you for confirmation before deletion
  7. Upon confirmation (typing "yes" or "y"), it deletes the events one by one

Safety Features

  • Requires explicit user confirmation before deleting any events
  • Shows all events that will be deleted before proceeding
  • Implements rate limiting with 100ms delay between deletions
  • Reports success and failure counts after completion

Notes

  • The query parameter searches both event summaries and descriptions
  • Only single events are returned (recurring events are expanded)
  • Events are ordered by start time for easier review
  • The tool uses the "primary" calendar by default (usually your main Google Calendar)
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"strings"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"
)
func main() {
var (
query string
calendarID string
timeMin string
timeMax string
)
flag.StringVar(&query, "query", "", "Search query to filter events by summary or description")
flag.StringVar(&calendarID, "calendar", "primary", "Calendar ID (default: primary)")
flag.StringVar(&timeMin, "time-min", "", "Minimum time for events (RFC3339 format, e.g., 2024-01-01T00:00:00Z)")
flag.StringVar(&timeMax, "time-max", "", "Maximum time for events (RFC3339 format, e.g., 2024-12-31T23:59:59Z)")
flag.Parse()
if query == "" {
log.Fatal("Error: -query flag is required")
}
ctx := context.Background()
// Get OAuth2 credentials from environment variable
credsJSON := os.Getenv("GOOGLE_OAUTH_CREDENTIALS")
if credsJSON == "" {
log.Fatal("Error: GOOGLE_OAUTH_CREDENTIALS environment variable is not set")
}
// Parse the credentials
config, err := google.ConfigFromJSON([]byte(credsJSON), calendar.CalendarScope)
if err != nil {
log.Fatalf("Unable to parse client secret: %v", err)
}
// Get the token (from cache or user authentication)
token := getToken(config)
// Create calendar service with OAuth2
client := config.Client(ctx, token)
service, err := calendar.NewService(ctx, option.WithHTTPClient(client))
if err != nil {
log.Fatalf("Unable to create calendar service: %v", err)
}
// Build events list request
listCall := service.Events.List(calendarID).
Q(query).
SingleEvents(true).
OrderBy("startTime")
if timeMin != "" {
listCall = listCall.TimeMin(timeMin)
}
if timeMax != "" {
listCall = listCall.TimeMax(timeMax)
}
// Fetch events
fmt.Printf("Searching for events matching query: %q\n", query)
events, err := listCall.Do()
if err != nil {
log.Fatalf("Unable to retrieve events: %v", err)
}
if len(events.Items) == 0 {
fmt.Println("No events found matching the query.")
return
}
// Display found events
fmt.Printf("\nFound %d event(s):\n\n", len(events.Items))
for i, event := range events.Items {
startTime := event.Start.DateTime
if startTime == "" {
startTime = event.Start.Date
}
fmt.Printf("%d. Event: %s\n", i+1, event.Summary)
fmt.Printf(" Description: %s\n", event.Description)
fmt.Printf(" Start: %s\n", startTime)
fmt.Printf(" ID: %s\n", event.Id)
fmt.Println()
}
// Ask for confirmation
fmt.Print("Do you want to delete these events? (yes/no): ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
log.Fatalf("Error reading input: %v", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "yes" && response != "y" {
fmt.Println("Deletion cancelled.")
return
}
// Delete events
fmt.Println("\nDeleting events...")
deletedCount := 0
failedCount := 0
for _, event := range events.Items {
err := service.Events.Delete(calendarID, event.Id).Do()
if err != nil {
fmt.Printf("Failed to delete event %q: %v\n", event.Summary, err)
failedCount++
} else {
fmt.Printf("Deleted: %s\n", event.Summary)
deletedCount++
}
// Small delay to avoid rate limiting
time.Sleep(100 * time.Millisecond)
}
fmt.Printf("\nDeletion complete. Deleted: %d, Failed: %d\n", deletedCount, failedCount)
}
// getToken retrieves a token from cache or prompts user for authorization
func getToken(config *oauth2.Config) *oauth2.Token {
tokenFile := "token.json"
token, err := tokenFromFile(tokenFile)
if err != nil {
token = getTokenFromWeb(config)
saveToken(tokenFile, token)
}
return token
}
// getTokenFromWeb requests a token from the web using a local server
func getTokenFromWeb(config *oauth2.Config) *oauth2.Token {
// Update the redirect URL to use localhost
config.RedirectURL = "http://localhost:8080/callback"
// Channel to receive the authorization code
codeChan := make(chan string)
errChan := make(chan error)
// Start local HTTP server to receive the OAuth callback
server := &http.Server{Addr: ":8080"}
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
if code == "" {
errChan <- fmt.Errorf("no authorization code received")
http.Error(w, "Authorization failed: no code received", http.StatusBadRequest)
return
}
// Send success message to browser
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `
<html>
<head><title>Authorization Successful</title></head>
<body>
<h1>Authorization Successful!</h1>
<p>You can close this window and return to the terminal.</p>
</body>
</html>
`)
codeChan <- code
})
// Start server in background
go func() {
if err := server.ListenAndServe(); err != http.ErrServerClosed {
errChan <- err
}
}()
// Generate and display authorization URL
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
fmt.Printf("Opening browser for authorization...\n")
fmt.Printf("If the browser doesn't open automatically, visit this URL:\n%v\n\n", authURL)
fmt.Printf("Waiting for authorization...\n")
// Try to open browser automatically
openBrowser(authURL)
// Wait for authorization code or error
var authCode string
select {
case authCode = <-codeChan:
// Successfully received code
case err := <-errChan:
log.Fatalf("Error during authorization: %v", err)
case <-time.After(5 * time.Minute):
log.Fatal("Authorization timeout: no response received within 5 minutes")
}
// Shutdown the server
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
server.Shutdown(ctx)
// Exchange authorization code for token
token, err := config.Exchange(context.Background(), authCode)
if err != nil {
log.Fatalf("Unable to retrieve token from web: %v", err)
}
fmt.Println("Authorization successful!")
return token
}
// openBrowser attempts to open the default browser with the given URL
func openBrowser(url string) {
var err error
switch {
case fileExists("/usr/bin/xdg-open"):
err = exec.Command("xdg-open", url).Start()
case fileExists("/usr/bin/open"):
err = exec.Command("open", url).Start()
case fileExists("/mnt/c/Windows/System32/cmd.exe"):
// WSL
err = exec.Command("cmd.exe", "/c", "start", url).Start()
}
if err != nil {
// Silently ignore browser open errors
return
}
}
// fileExists checks if a file exists
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// tokenFromFile retrieves a token from a local file
func tokenFromFile(file string) (*oauth2.Token, error) {
f, err := os.Open(file)
if err != nil {
return nil, err
}
defer f.Close()
token := &oauth2.Token{}
err = json.NewDecoder(f).Decode(token)
return token, err
}
// saveToken saves a token to a file path
func saveToken(path string, token *oauth2.Token) {
fmt.Printf("Saving credential file to: %s\n", path)
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
log.Fatalf("Unable to cache oauth token: %v", err)
}
defer f.Close()
json.NewEncoder(f).Encode(token)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment