A Google Calendar table provider and action set for Saltcorn. Events and calendars become ordinary Saltcorn tables you can build views on, and five actions let triggers and workflows write back.
This is a data-layer module: it adds table providers and actions, and no UI of
its own. An events table renders in @saltcorn/fullcalendar without any
further configuration.
The plugin's configuration page shows the exact redirect URI to register, and updates it as you type the base URL — so open it first and work from there.
Enter this Saltcorn instance's public URL. The page then shows the redirect URI you need in the next step, in the shape:
<your base URL>/google-calendar/oauth2/callback
Until the base URL is filled in, the page shows a placeholder host and says so. Only the value shown once your base URL is in place is the one to register.
http://localhost:3000 is fine for development. Localhost is the one exception
to Google's HTTPS requirement for redirect URIs.
In the Google Cloud console:
- Enable the Google Calendar API for your project (direct link). Nothing else works until this is on — an otherwise correct setup fails with a 403 naming the API.
- Under APIs & Services → Credentials, create an OAuth 2.0 Client ID of type Web application.
- Paste the redirect URI from step 1 under Authorised redirect URIs. It must match character for character, including scheme and port.
- Under Audience, add your own Google account as a test user — see Publishing status below.
Paste the client ID and secret, then continue to the Connect step and press Connect Google account.
Two other settings on the first step:
- Read only requests the
calendar.readonlyscope instead ofcalendar. Provider tables and actions can then read events but not change them. - Notify attendees by default controls whether Google emails attendees about writes made through a provider table. The actions ask per action.
A Google Cloud project with an external user type and a publishing status of Testing issues refresh tokens that expire after 7 days. This plugin stores one long-lived refresh token, so a Testing-mode connection stops working after a week and every provider table starts erroring until you reconnect.
| Your account | Do this | Result |
|---|---|---|
| Google Workspace | Set the user type to Internal | No 7-day expiry, no verification needed |
| Personal Gmail | Publish app (In production) | Long-lived tokens; you click through an "unverified app" warning |
| Either, short term | Stay in Testing | Works, but reconnect weekly |
Verification is only needed to remove the warning screen and to go beyond 100 users. It is not needed to use this for yourself.
Error 400: redirect_uri_mismatch — the URI registered in Google Cloud is
not byte-identical to the one the plugin sends. Copy it from the plugin
configuration page rather than typing it. http://localhost and
http://127.0.0.1 are different URIs to Google.
Error 403: access_denied, "can only be accessed by developer-approved
testers" — the consent screen is in Testing and your account is not on the
test-user list. Add it under Audience → Test users.
A 403 naming the Calendar API — the API is not enabled for the project.
Bad Request (HTTP 400) on a write — Google rejected the event body. The
error now quotes the offending property. Most often it is an all-day event
whose end does not fall after its start, or a constrained property
(status, transparency, visibility, color_id) set to something other
than one of its permitted values.
Everything stops working after about a week — the 7-day refresh token expiry above.
Create a table with Google Calendar as the provider, then choose:
- Events — one calendar's events. Pick the calendar, whether to expand recurring events into occurrences, a default time window and a page cap.
- Calendars — the calendars the connected account can see. Read only.
id, calendar_id, summary, description, location, start, end,
all_day, status, organizer_email, creator_email, attendees,
attendee_count, recurring_event_id, recurrence, transparency,
visibility, color_id, meet_link, html_link, ical_uid, created,
updated.
id is the Google event id and the table's primary key, so
sync_table_from_external can match rows across runs.
attendees is a comma-separated list of email addresses. Writing to it
replaces the attendee list.
For all-day events, end is exclusive, exactly as the Google API reports
it: a one-day event on 25 December has end of 26 December. This is also what
FullCalendar expects. Shifting it would make the row disagree with the same
event read through any other client.
The provider does its own filtering, ordering and paging. Conditions that map onto the API are sent to Google, and everything is re-checked locally, so a filter the API cannot express still returns the right rows — it just costs more requests.
| Filter | Sent to Google as |
|---|---|
id equals a string |
a direct events.get — one request |
start/end lower bound |
timeMin |
start/end upper bound |
timeMax |
updated lower bound |
updatedMin |
ical_uid equals |
iCalUID |
| full-text search | q |
status includes cancelled |
showDeleted |
Everything else — ilike, in, or, not, numeric comparisons on
attendee_count, and so on — is evaluated locally over the fetched window.
inSelect and the other SQL-only operators raise an error naming the operator
rather than silently returning no rows.
Sorting by start ascending uses the API's own order, which lets a paged view
stop fetching as soon as it has enough rows. Any other sort has to read the
whole window first.
A query carrying no date filter is bounded to the configured window — 365 days either side of now by default. Without it, the first render of an unfiltered list view walks every event since the account was created. Set it to 0 to fetch everything, and raise the page cap to match.
Note that a paged view costs two walks of the window, because the Calendar API
has no count endpoint and countRows has to fetch the matching rows.
Insert, update and delete work on an Events table when the plugin is not connected read-only.
Updates are sent as PATCH, so a row carrying one changed column moves that
column and leaves the rest alone.
deleteRows refuses an empty filter. Deleting every event on a real calendar
cannot be undone from Saltcorn, so it has to be asked for explicitly.
| Action | Use |
|---|---|
google_calendar_create_event |
Create an event from a row expression |
google_calendar_update_event |
Patch an event by id |
google_calendar_delete_event |
Delete an event by id |
google_calendar_find_events |
Read events into a workflow variable |
google_calendar_free_busy |
Busy periods across several calendars |
The event expression returns the same shape the provider's rows use, so a row read from a Google Calendar table can be written straight back out:
{summary: title, start: starts_at, end: ends_at, attendees: contact_email}In a workflow, find_events and free_busy take a result variable and fill
it with an array — busy periods as {calendar_id, start, end}.
A provider table reads live, which means no joins to local tables, no history
and a request per view render. To keep a local copy, use the built-in
sync_table_from_external action with the Google Calendar table as the source
and id as the matching field. Run it on a schedule.
Use the provider table directly for calendars people are looking at now, and a synced copy for anything that needs to be joined, reported on or kept.
Tokens are stored in the plugin configuration, so one connected Google account
serves the whole instance — the same model core uses for SMTP OAuth. Actions
therefore act as that account, not as the logged-in user. Per-user connections
would need a token table keyed by user id; lib/oauth.js is the only module
that would change.
Access tokens refresh automatically. Concurrent requests share one refresh, so a page rendering several views does not fire several refresh grants at once.
npm test
73 tests covering the where-to-query translation, the local filter and sort,
the row mapping, paging and early stop, the retry and token-refresh behaviour,
and the configuration pages. Nothing touches the network: fetch is injected, and the token
source is substituted.
The saltcorn packages are resolved through symlinks in node_modules:
ln -sfn /path/to/saltcorn/packages/saltcorn-data node_modules/@saltcorn/data
ln -sfn /path/to/saltcorn/packages/saltcorn-markup node_modules/@saltcorn/markup
| File | Holds |
|---|---|
index.js |
plugin registration and the configuration workflow |
lib/config.js |
endpoints, scopes, live-config lookup |
lib/oauth.js |
the consent flow, token refresh and persistence |
lib/api.js |
authenticated fetch, retries, paging |
lib/query.js |
where → query parameters, local filter and sort |
lib/fields.js |
column definitions and row mapping |
lib/provider.js |
the table provider |
lib/actions.js |
the five actions |
lib/routes.js |
the OAuth redirect and callback routes |
lib/config.js holds CALLBACK_PATH, the single place the callback path is
spelled. The route and the setup instructions both derive from it, and a test
asserts they agree — a mismatch between them is redirect_uri_mismatch.
lib/oauth.js, lib/api.js and the pushdown approach in lib/query.js are
written to be lifted into a shared integrations package once a second Google
or REST integration needs them.
MIT