koep/gcal-cleaner

A Google Calendar sidebar add-on (Apps Script) that copies the open event to a chosen secondary calendar—without copying guests—preserves the Meet link in the description, and declines your RSVP on the original (optionally with a guest-list comment via script properties).

★ 0Forks 0JavaScriptGitHub ↗Compare

README

gcal-cleaner

What it is: A Google Calendar sidebar add-on (Google Apps Script). You open a meeting, click one button, and it copies that event to another calendar you choose (without copying the guest list), keeps the Meet join link in the description, and sets your RSVP to “declined” on the original when Calendar allows it.

What you need: A Google account, Calendar, a second calendar where copies should go, and a few minutes to paste this project into Apps Script (or use clasp).


Setup (short version)

  1. Open script.google.com, create a project, and replace its code with Code.gs and appsscript.json from this repo. Turn on: Project settings → Show “appsscript.json” manifest file.
  2. Under Services, confirm Google Calendar API is enabled (the manifest turns it on; clasp push keeps it).
  3. Project settings → Script properties: add DESTINATION_CALENDAR_ID = the calendar ID where copies go (Calendar settings → Integrate calendar — often looks like [email protected], or primary for your main calendar).
  4. Optional — same Script properties: DECLINE_COMMENT = text stored as the Calendar API attendee response comment when you decline the original (trimmed; values longer than 2000 characters are truncated). Omit this property to decline without a comment.
  5. Deploy → Test deployments → Install so the add-on is available to your account in Calendar.
  6. In Google Calendar, open an event, open the add-on from the side panel, click Copy to secondary & decline here.

If anything fails, see Troubleshooting below.


How to use

  1. Open the event in Calendar (web).
  2. Open gcal-cleaner in the add-on sidebar.
  3. Click Copy to secondary & decline here.
  4. Check the destination calendar for the new event.
  5. On the original event, confirm your response is declined when the add-on could update the guest list (see Decline limitations).

What it does (and does not do)

Does:

  • Shows a small card when you open an event.
  • On button click: reads the event, then either inserts a new copy on DESTINATION_CALENDAR_ID (title, time including all-day, location, reminders if present, and description) or, if a copy already exists for this event, updates that copy in place instead of creating a duplicate (see Updates and duplicates).
  • If there is a Google Meet link, appends Google Meet: <url> to the description when that URL is not already there (link is taken from hangoutLink or the video entry point in conferenceData).
  • Patches your attendee row on the original to declined with sendUpdates: 'none' so others are not emailed by that change. This runs every time you click, so it also re-declines after an organizer edit resets your RSVP. If DECLINE_COMMENT is set in Script properties, that PATCH also sets attendees[].comment on your row so organizers can see the note in the guest list (still no extra email from sendUpdates: 'none').

Does not:

  • Run on a schedule or scan your calendars automatically — sync only happens when you reopen the event and click the button again.
  • Copy the guest list to the secondary event (by design).
  • Auto-propagate an organizer's edit to a whole recurring series you've already copied — each occurrence has its own copy, and each one only updates when you reopen that specific occurrence.
  • Expand recurring series — it copies only the instance you opened.
  • Create a new Meet conference on the copy; you get the same join URL in text, not necessarily the same chip UI as on the original.

Updates and duplicates

Each copy is tagged with a hidden private extended property (a hash of the source calendar + event ID). Clicking the button:

  1. Looks up the destination calendar for a copy already tagged with that event.
  2. Found: patches the existing copy's summary/time/location to match the source. The copy's description is left alone (only a missing Meet link gets appended) so personal notes you added on the copy survive.
  3. Not found: inserts a new tagged copy.

This means clicking the button again — including after the organizer edits the original and resets your RSVP to "needs action" — updates the same copy and re-declines, instead of creating a second one.

Recurring events: a recurring instance has its own stable per-occurrence event ID, so each occurrence you open gets (and later updates) its own copy. If the organizer edits the whole series, previously-copied occurrences won't update until you reopen each of them — there's no series-wide sync, since the add-on only runs when you open an event, not on a schedule.


How it fits together

flowchart LR
  subgraph client [Google Calendar]
    User[You]
    UI[Event + add-on sidebar]
  end
  subgraph platform [Apps Script]
    Trig[onEventOpen]
    Act[copyEventToSecondaryAndDecline]
  end
  API[Calendar API v3]
  User --> UI
  UI --> Trig
  UI --> Act
  Trig --> API
  Act --> API
Loading

When you open an event, the host calls onEventOpen, which reads the event and builds the card. The button runs copyEventToSecondaryAndDecline, which creates the personal copy and tries to decline your row on the original.


Security and privacy

  • No secrets in this repo. Put the destination calendar ID in Script properties (DESTINATION_CALENDAR_ID), not in source code. Anyone with edit access to the Apps Script project can read script properties.
  • OAuth: The manifest requests Calendar add-on scopes plus https://www.googleapis.com/auth/calendar, which is broad (read/write calendars you can access). That matches what Events.get / insert / patch need; narrower scopes are not enough for this flow.
  • Runs as you: API calls use the account that opened the add-on. The script does not send calendar data to a third-party server; execution stays in Apps Script + Google APIs.
  • Copy content: Title, time, location, description (and Meet URL in description) are copied. Attendees are not copied, which avoids copying others’ email addresses into the secondary event.
  • Shared destination calendar: If you share that calendar, people with access can see the copied events and anything in the description (including the Meet link).
  • Button parameters: The card passes calendarId and eventId into the click handler. The code validates length and control characters. Operations still require your Calendar permissions; another user cannot use your run to read calendars they should not see.

Errors are logged to Stackdriver (exceptionLogging in the manifest); avoid putting confidential data in event titles if org policy restricts log access.


OAuth scopes (from appsscript.json)

Scope Why
calendar.addons.execute Run as a Calendar add-on.
calendar.addons.current.event.read Read the opened event (currentEventAccess: READ).
calendar Calendar.Events.get / insert / patch on your calendars.

Authorization often completes the first time you open the add-on in Calendar, not only when you click Run in the editor.


Google Meet behavior

Calendar may show Meet as a conference chip. This add-on does not attach a new native conference on the copy. It puts the join URL in the description (and copies location from the original). Joining from that link still works; the UI may differ from the original.


Decline behavior and limitations

The script sets responseStatus: 'declined' on your attendee entry and uses sendUpdates: 'none'.

Decline is skipped when:

  • The event has no attendees (or an empty list), or
  • You are not in the list (no row matching your email or self).

The copy is still created; the notification says if decline could not be applied.

Changing sendUpdates to 'all' in code could email organizers or guests about the decline — the default avoids that.


Troubleshooting

Issue What to check
Calendar is not defined Enable Google Calendar API advanced service.
“Set the DESTINATION_CALENDAR_ID script property” Add the property under Project settings.
Authorization errors Re-authorize; confirm all scopes are granted.
Add-on missing or card not updating Install a test deployment; keep the add-on sidebar open on an open event.
Copy fails with 403 Destination must be yours and writable; check the calendar ID.
Decline never applies See Decline behavior and limitations.
clasp login / push errors Run clasp login again; use an account that owns the script.

Prerequisites (reference)

  • Google account with Calendar.
  • Secondary (or other writable) calendar and its Calendar ID.
  • For clasp: Node.js and npm install -g @google/clasp.

Setup details (browser)

Create the project: script.google.com → new project → paste Code.gs and appsscript.json. Remove sample files you do not need. Enable the manifest file in Project settings.

Deploy: Deploy → Test deployments → Install. For personal use you do not need the Workspace Marketplace. More: Test and debug Workspace add-ons.


Develop with clasp

clasp syncs this folder with Apps Script. Install: npm install -g @google/clasp, then clasp login.

Command Purpose
clasp clone --rootDir . "<scriptId>" Link folder to an existing project and pull files.
clasp open Open the project in the browser editor.
clasp push Upload local files.
clasp pull Download from Google (overwrites local — use carefully).
clasp logs Stream logs.
clasp push --watch Push on file changes.

Link an existing cloud project: Apps Script → Project settings → copy Script ID → clasp clone --rootDir . "<scriptId>" → replace/pull as needed → clasp push.

New project from this repo: Create a standalone script in the browser, clone with clasp, replace with this repo’s Code.gs and appsscript.json, push. Alternatively clasp create --title "gcal-cleaner" --type standalone in an empty folder, then add the manifest and code from here (some clasp versions overwrite appsscript.json — keep a backup).

Treat Script ID in .clasp.json as personal; teammates often use their own clone or keep .clasp.json out of git.

After changing appsscript.json, push and re-authorize or redeploy if Google asks.


Repository layout

File Purpose
Code.gs Add-on entry points, copy + decline logic
appsscript.json Runtime, OAuth scopes, add-on manifest, Calendar API service
.clasp.json Local Script ID — from clasp clone / create; often not committed
README.md This document

Logo

The manifest’s logoUrl points at a public Google image. You may replace it in appsscript.json with any stable https:// image you are allowed to use.

Contributors

koep

Issues