Method – Google Calendar Sync for The Events Calendar
Keeps a rolling window of events from one or more public Google Calendars synced into The Events Calendar. Field-level three-way merge preserves local edits; source deletions trash the local event; venues are matched or created through TEC's venue API so Method – Mapbox for The Events Calendar geocodes them.
Install
Upload it under Plugins → Add New Plugin → Upload Plugin, then activate.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/pixelwatt/method-tec-gcal-sync/releases/download/v0.1.1/method-tec-gcal-sync.zipKeeps a rolling window of events from one or more public Google Calendars synced into The Events Calendar. Built to sit alongside Method – Mapbox for The Events Calendar: venues are created through TEC's own venue API, so the Mapbox plugin geocodes them with no coupling between the two.
- Field-level three-way merge. Edit a synced event locally and that field stops following Google; every other field keeps syncing. "Reset to Google values" puts it back.
- Source deletions are honoured. An event removed from Google is trashed (or deleted) locally. Events you trash locally are never re-created.
- Venues matched, then created. Google's one-line
locationresolves to an existing venue by alias → name → street address; only then is a venue created. - Rolling window. Only events from lookback days ago through window days ahead are touched. A
month_startoption opens the window on the 1st of the current month instead — handy at launch so the current month looks complete. - Multiple calendars, each optionally tagged with an Event Category.
- Runs via Action Scheduler (bundled with TEC), WP-Cron, WP-CLI, the settings page, or an external cron hitting a REST endpoint.
Requires The Events Calendar and PHP 7.4+. CMB2 is needed only for the settings UI.
Google setup (free tier)
The Calendar API has no billing requirement and a default quota of 1,000,000 queries/day. One sync is one events.list call per calendar (plus one events.get per event that vanished from the window), so an hourly sync of three calendars is ~75 calls/day.
- console.cloud.google.com → create a project → APIs & Services → Library → enable Google Calendar API.
- APIs & Services → Credentials → Create credentials → API key. Under API restrictions, restrict the key to the Calendar API. Leave Application restrictions at None — HTTP-referrer restrictions don't apply to server-side requests, and IP restrictions break on hosts with floating egress IPs (WP Engine).
- In Google Calendar, for each calendar: Settings → Access permissions for events → Make available to public (the "See all event details" option).
- Same settings page, Integrate calendar → Calendar ID — that is what you paste into the plugin (looks like
xxxx@group.calendar.google.com).
Configuration
Any of these, in increasing precedence:
Standalone page — Events → Google Calendar Sync (CMB2 options page, option key method_tec_gcal_sync).
Method options page — append the same fields to Method's unified options page (keys land in method_options prefixed gcal_), mirroring cmb2-mapbox's pattern:
add_filter( 'method_gcal_sync_use_method', '__return_true' );
Code only — overrides whatever is stored; no CMB2 needed:
add_filter( 'method_gcal_sync_settings', function ( array $s ): array {
$s['api_key'] = 'AIza…';
$s['calendars'] = [
[ 'calendar_id' => 'xxx@group.calendar.google.com', 'label' => 'City Council', 'category' => 'council' ],
[ 'calendar_id' => 'yyy@group.calendar.google.com', 'label' => 'Parks & Rec' ],
];
$s['window_days'] = 120; // default 90
$s['window_start'] = 'lookback'; // or 'month_start' — see below
$s['lookback_days'] = 1; // default 1
$s['interval'] = 30; // minutes; 0 disables the scheduled run (default 60)
$s['post_status'] = 'publish';
$s['delete_mode'] = 'trash'; // or 'delete'
$s['author'] = 0; // post author for new events
return $s;
} );
Save settings, then use Preview (dry run) on the settings page before the first real Sync now. The dry run lists every create/update/trash it would perform and every venue it would create.
How a sync works
The window runs from its start through window_days ahead. The start is lookback_days before now, or — with Window starts: First day of the current month — 00:00 on the 1st in the site's timezone, whichever is earlier (so an event still running across the 1st is never dropped from the window and mistaken for a deletion). Month mode is meant for a launch: turn it on, sync, and switch back; leaving it on just widens the window and costs nothing but a slightly larger fetch.
For each calendar it fetches every single-instance event in the window (singleEvents=true expands recurrences server-side; each instance has a stable id and becomes an independent TEC event), then:
| Google says | Local state | Result |
|---|---|---|
| event present | no local post | create |
| event present | local post | per-field merge (below) |
| event present | local post in Trash | skip — a trashed event is never resurrected |
status: cancelled |
local post | trash/delete |
| absent from window | local post with start inside the window | one events.get: gone → trash/delete; still exists → it was moved, so update |
| absent from window | local post with start outside the window | left alone |
The merge
Each synced post keeps _gcs_snapshot: for every synced field, the value the importer last wrote as it landed in the database (so kses/slashing round-trips don't read as edits), or for a field it left alone, what Google had at the time. On each run, per field:
| snapshot | local vs snapshot | action |
|---|---|---|
| none (new, or after reset) | — | write source |
| exists | equal → not edited locally | write source if it changed |
| exists | different → edited locally | keep local; snapshot records the current source value |
Synced fields: title, content, start_date, end_date, all_day, timezone, venue, url (from Google's source.url, when set). Dates are written as a set through TEC's ORM so UTC and duration are recomputed; a locally-edited end date survives a source start-date change.
The event editor shows a Google Calendar box: source link, Google's raw location string, last sync, which fields are currently being preserved, and a Reset to Google values button (deletes the snapshot and re-syncs that one event). The events list has a column with the calendar label and a pencil icon when local edits are being kept.
Cancellations: Google has no "cancelled but still on the calendar" state for the owner's own events — in the API, cancelled means deleted. If an editor retitles an event "CANCELED – …", that's just a title change and syncs like one.
Venues
Google's location is a free-text line. Resolution order, first hit wins:
- Alias — a venue with the exact string in its
_gcs_location_aliasmeta. Every match or create adds one, and the venue edit screen has a textarea to add your own, so you can point a Google location at an existing venue instead of letting the sync create another. - Match — normalised comparison (case, punctuation,
Street→st,Circle→cir,S.W.→sw, …) against existing venue names and street addresses; a street match also requires the city to agree when both sides have one. - Create — the string is split on commas into name / street / city / state / ZIP / country (handles
Name, 123 Street, City, ST 12345, USA,Name, City, ST,City, ST, bare names likeZoom) and created withTribe__Events__Venue::instance()->create( $data, 'publish', true ). That firestribe_events_venue_created( $venue_id, $data )with TEC's classic keys (Address,City,StateProvince,Zip,Country), which is exactly what method-tec-mapbox'son_venue_save→build_address()consumes — so new venues are geocoded and get their cmb2-mapbox pin on creation. Thetrueturns on TEC's own title+address duplicate finder as a final guard.
A venue on a synced event is a synced field: reassign it locally and it's preserved like any other edit. If a venue can't be resolved or created, the local venue is left untouched rather than cleared.
Running it
- Scheduled: Action Scheduler when available (it is, with TEC), otherwise WP-Cron, at the configured interval. The schedule is reconciled on
init, so changing the interval takes effect on the next page load. - Settings page: Sync now / Preview (dry run); the last run and the last preview are shown with their full line-by-line log.
- WP-CLI:
wp method gcal-sync [--dry-run] [--calendar=<id>[,<id>]] [--post=<id> [--reset]] [--quiet-lines] - REST (for an external cron):
GET /wp-json/method-gcal-sync/v1/run?key=SECRET[&calendar=ID][&dry_run=1]— the secret is shown on the settings page. On WP Engine, pointing a real cron at this is more reliable than depending on WP-Cron.
A transient lock prevents overlapping runs. Each run's report is stored in the method_tec_gcal_sync_last_run option.
Hooks
Filters:
| Filter | Purpose |
|---|---|
method_gcal_sync_settings( array $settings ) |
Final say on every setting. |
method_gcal_sync_use_method( bool ) |
Put the fields on Method's options page. |
method_gcal_sync_settings_url( string ) |
Where "back to settings" goes in Method mode. |
method_gcal_sync_window_start( int $timestamp, int $now, array $settings ) |
Override where the window opens (fires only in month_start mode). |
method_gcal_sync_rest_secret( string ) |
Override the REST key. |
method_gcal_sync_source_fields( array $fields, array $item, array $calendar ) |
Change what's considered the source value of each synced field (e.g. prefix titles, inject cost). |
method_gcal_sync_event_args( array $args, array $item, array $calendar, int $post_id ) |
The tribe_events()->set_args() payload, before create/update. |
method_gcal_sync_resolve_venue( int $venue_id, string $location ) |
Return a venue id to short-circuit resolution. |
method_gcal_sync_parse_location( array $parts, string $location ) |
Adjust the parsed name/address/city/state/zip/country. |
method_gcal_sync_venue_data( array $data, string $location, array $parts ) |
The TEC venue data array before creation. |
Actions:
| Action | Fires |
|---|---|
method_gcal_sync_before_run( array $settings, bool $dry_run ) |
Start of a run. |
method_gcal_sync_after_run( Report $report, array $settings, bool $dry_run ) |
End of a run. |
method_gcal_sync_event_synced( int $post_id, array $item, bool $created, array $written_fields, array $preserved_fields ) |
After each create/update. |
method_gcal_sync_before_remove( int $post_id, string $reason ) |
Before a trash/delete. |
method_gcal_sync_venue_created( int $venue_id, string $location, array $parts ) |
After a venue is created. |
Meta reference
Events: _gcs_calendar_id, _gcs_event_id (Google instance id), _gcs_ical_uid, _gcs_recurring_id, _gcs_updated (Google's updated), _gcs_link (htmlLink), _gcs_location (raw string), _gcs_synced (timestamp), _gcs_snapshot (merge state).
Venues: _gcs_location_alias (repeated), _gcs_created (set only on venues this plugin made).
Known limits
- Recurring series are expanded into independent events. If a series is restructured on Google (start time of the whole series changed), Google issues new instance ids; the old instances are trashed and the new ones created, so local edits on those instances are lost.
- Events already on the site before the plugin ran are not adopted; the first sync creates fresh posts for everything in the window. Trash the originals or narrow the window first.
- Images in Google descriptions are not imported (Google strips them from the API's
descriptionanyway). - Updates go through
tribe_events()->where( 'id', $id )->set_args( … )->save(). If a TEC release changes that path,write()inclass-sync.phpis the one place to adjust.
Multisite
Site-activate it on the subsite that needs it; no network support is required or provided. Settings, the run lock, the schedule, the REST secret and all lookups are per-site. Two consequences of that:
- WP-CLI needs the site:
wp --url=events.example.org method gcal-sync --dry-run. - WP-Cron (and Action Scheduler's default runner) only fires on requests to that subsite, so a quiet subsite may go hours between runs. Point a real cron at the subsite's REST trigger URL instead.
Network activation also works — each site gets its own schedule and settings — and network deactivation clears the schedule on every site.
Releases
2 releases. Each count is every asset in that release; expand a row for the breakdown.