Calendar Sync Internals (Pro)
Calendar sync is Pro's saved-subscription layer on top of Free's import pipeline. A feed is a saved remote source (an iCalendar URL today; Eventbrite/Meetup next) that re-imports on a cadence, so a connected calendar stays current with no member effort.
The whole feature is assembled from public Free seams. Pro registers an import source through evnm_import_sources, executes it through ImportRunnerInterface, and hangs its admin screen off evnm_admin_rail_groups. Nothing in Free was edited to make it work, which means a third-party add-on can build the same thing.
Pro plugin. Everything on this page ships in
eventonomy-pro. The seams it consumes (evnm_import_sources,ImportRunnerInterface,evnm_admin_rail_groups) are Free and documented in Hooks & Filters and Extending.
What You Will Learn
- The
evnm_pro_feedstable and what each column means - The
FeedRepositoryInterfacecontract and how to resolve it - How
FeedRunnerexecutes one feed against Free's import runner - How
CalendarSyncSchedulerProviderschedules, bounds, and self-heals the sweep - The member and admin REST routes
- How a new sync source registers itself
- How feed tokens are encrypted at rest and kept out of REST output
The Moving Parts
| Piece | File | Role |
|---|---|---|
evnm_pro_feeds table |
includes/Core/Migrator.php:135 |
Stores the subscriptions. |
FeedRepositoryInterface |
includes/Contracts/FeedRepositoryInterface.php |
The only sanctioned read/write path. |
FeedRepository |
includes/Repository/FeedRepository.php |
Concrete store (@internal). |
FeedRunner |
includes/Services/FeedRunner.php |
Runs one feed to completion. |
CalendarSyncSchedulerProvider |
includes/Providers/CalendarSyncSchedulerProvider.php |
Hourly sweep, cadence gate, self-heal. |
CalendarFeedController |
includes/REST/CalendarFeedController.php |
Member self-service routes. |
AdminCalendarFeedController |
includes/REST/AdminCalendarFeedController.php |
Site-owner routes. |
Secret |
includes/Support/Secret.php |
AES-256-GCM at-rest encryption for tokens. |
CalendarSyncProvider |
includes/Providers/CalendarSyncProvider.php |
Registers the remote-ics source. |
SyncSourcesAdminProvider |
includes/Providers/SyncSourcesAdminProvider.php |
Rail item + admin screen wiring. |
The evnm_pro_feeds Table
Created by Pro's version-gated migrator at schema version 4 (includes/Core/Migrator.php:26, table definition at :135).
CREATE TABLE {prefix}evnm_pro_feeds (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
owner_id bigint(20) unsigned NOT NULL DEFAULT 0,
source_type varchar(32) NOT NULL,
config longtext NOT NULL,
cadence varchar(16) NOT NULL DEFAULT 'daily',
status varchar(16) NOT NULL DEFAULT 'active',
last_run_utc datetime NULL DEFAULT NULL,
last_hash varchar(64) NULL DEFAULT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (id),
KEY owner_status (owner_id, status),
KEY due (status, cadence)
) ENGINE=InnoDB;
| Column | Meaning |
|---|---|
owner_id |
The member who owns the feed (Model B, self-service). 0 is the site-wide bucket (Model A, admin-curated) - not "no owner". |
source_type |
A source id registered on evnm_import_sources, e.g. remote-ics or eventbrite. |
config |
JSON payload handed to the source, e.g. {"url":"https://…/cal.ics"} or {"token":"evnmg1:…"}. Decoded to an array by every repository read. |
cadence |
hourly / daily / weekly / monthly. |
status |
active feeds are swept; anything else is skipped. |
last_run_utc |
UTC datetime of the last run. NULL means "never run", which is always due. |
last_hash |
Reserved for content-hash short-circuiting. |
Both indexes exist for the two real access paths: owner_status serves the member list and the per-member cap, due serves the scheduler sweep. Every listing method is bounded - find_all() takes an explicit $limit / $offset and count_all() is a separate COUNT(*), so the admin screen paginates rather than selecting the table.
FeedRepositoryInterface
Resolve it from Free's container - never construct FeedRepository directly.
use Eventonomy\Pro\Contracts\FeedRepositoryInterface;
$feeds = evnm( FeedRepositoryInterface::class );
The contract is bound in includes/Providers/FeedRepositoryProvider.php on the evnm_register_services action, so a later bind() can swap the store the same way any other Eventonomy contract can.
| Method | Signature | Notes |
|---|---|---|
create() |
create( array $data ): int |
$data = owner_id, source_type, config (array), cadence, status. Returns the new id, or 0 on failure. |
get() |
get( int $id ): ?array |
Feed row with config decoded to an array, or null. |
find_for_owner() |
find_for_owner( int $owner_id ): array |
A member's feeds, newest first. 0 returns the site-wide feeds. |
find_due() |
find_due( string $cadence, int $limit = 100 ): array |
Active feeds for one cadence sweep. |
find_active() |
find_active( int $limit = 100 ): array |
Active feeds across all owners, oldest run first, so nothing starves. |
update() |
update( int $id, array $data ): bool |
Mutable fields only: config, cadence, status, last_run_utc, last_hash. config is accepted as an array. |
delete() |
delete( int $id ): bool |
Removes the subscription only - already-imported events stay. |
count_for_owner() |
count_for_owner( int $owner_id ): int |
Backs the per-member cap. |
find_all() |
find_all( int $limit, int $offset = 0 ): array |
Every feed, newest first. $limit is clamped to 1..200. |
count_all() |
count_all(): int |
COUNT(*) for the admin pager. |
owner_of() |
owner_of( int $id ): int |
Owner id for an authorization check, or -1 when the feed does not exist. Do not confuse that with 0 (site-wide). |
FeedRunner
FeedRunner (includes/Services/FeedRunner.php) executes exactly one feed, one bounded page at a time.
use Eventonomy\Pro\Contracts\FeedRepositoryInterface;
use Eventonomy\Pro\Services\FeedRunner;
$feeds = evnm( FeedRepositoryInterface::class );
$feed = $feeds->get( 12 );
$result = ( new FeedRunner( $feeds ) )->run( $feed );
// $result: array{ok:bool, created:int, skipped:int, done:bool, queued:bool, message:string}
run() imports the FIRST page (100 rows, FeedRunner::BATCH_SIZE) and, if rows
remain, queues the next page and returns with done => false, queued => true.
It previously ran the feed to completion in a single request, which meant one
large calendar was one long request; the sweep's wall-clock budget only applied
BETWEEN feeds, so it could not help.
What a page actually does:
- Validates the row. A missing id or empty
source_typereturnsok => false, message => 'invalid_feed'without touching anything. - Resolves Free's
ImportRunnerInterfacefrom the container. If it is unavailable the run returnsmessage => 'no_runner'- Pro never names Free's concreteImportService. - Builds the import context:
user_id/author_id- the feed owner whenowner_id > 0(the member owns their synced events), otherwiseapply_filters( 'evnm_pro_sync_default_author', 1 ).user_idis whatEventService::create()reads, and it also drives the approval gate, so a non-moderator member's imports land as pending.dedup_source_id-"{source_type}:{feed_id}". This is the load-bearing part: two feeds that share theremote-icssource get separate dedup and undo namespaces, so removing one feed's events never touches the other's.import_source-"evnm_pro_feed:{feed_id}".
- Calls
run_source_page( $source_type, $config, $context, $offset, 100 )- Free's bounded-read seam, so the page reads only its own rows rather than re-reading the whole feed. - Writes
last_run_utc = gmdate( 'Y-m-d H:i:s' )before each page, as a lease. Stamping per page is what keeps the hourly sweep off a walk that is still progressing: the feed does not read as due again until its cadence elapses after the LAST page, and a walk that dies stops re-stamping and is picked up again on schedule. - Records the outcome on the feed:
last_statusisokwhen the walk finished,syncingmid-walk,erroron failure (with a truncatedlast_error). A mid-walk page deliberately does NOT reportok- a walk that dies at page 3 must not leave the Sync Sources screen claiming a clean sync. - If rows remain, queues
FeedRunner::SYNC_ACTION(evnm_pro_sync_feed_page) with{ feed_id, offset, created, skipped }.
A continuation job re-reads the feed by id, so a feed deleted or reconfigured
mid-walk stops cleanly (message => 'feed_gone') instead of finishing against a
stale snapshot. A failed page ends the walk rather than retrying the same offset -
the cadence sweep is the retry. Restarting from offset 0 is safe because dedup is
namespaced per feed, so a lost cursor costs a re-scan, never duplicate events.
Failures are summarized in the return value rather than thrown - the scheduler sweep must survive one bad feed.
Job args are WRAPPED
schedule_page() queues array( $args ), not $args. Both Action Scheduler
and WP-Cron dispatch a job with do_action_ref_array( $hook, $args ), spreading
the array's values into positional callback arguments - so the two call sites must
pass the same shape or the job silently works on only one rail. See
docs/standards/BACKGROUND-JOBS.md in the Pro repo, and the regression sentinel
audit/journeys/admin/07-background-jobs-drain.md.
The Scheduler - CalendarSyncSchedulerProvider
The scheduler is includes/Providers/CalendarSyncSchedulerProvider.php. There is no FeedScheduler class; the provider is the whole thing, built from three namespaced functions plus a hook constant.
const EVNM_PRO_CALENDAR_SYNC_HOOK = 'evnm_pro_calendar_sync';
One hourly master sweep, not one schedule per feed. On init, evnm_pro_calendar_sync_ensure_scheduled() checks whether any active feed exists (find_active( 1 )) and:
- schedules a recurring hourly action in the
eventonomy-progroup viaas_schedule_recurring_action()when Action Scheduler is present; - falls back to
wp_schedule_event( …, 'hourly', … )when it is not; - unschedules itself when the last feed goes away. The sweep also re-runs this check when it finds zero feeds, so the schedule self-heals in both directions.
evnm_pro_calendar_sync_sweep() runs on the hook:
- Pulls up to
apply_filters( 'evnm_pro_calendar_sync_batch', 50 )active feeds, oldest run first. - For each,
evnm_pro_calendar_feed_is_due( $feed )decides. A feed with nolast_run_utcis always due; otherwise elapsed time must reach the cadence interval minus a 5-minute slack, so an "hourly" feed that ran at :59 still fires the next hour. - Due feeds go through
FeedRunner::run().
Cadence intervals are hourly = 1 hour, daily = 1 day, weekly = 1 week, monthly = 30 days; an unrecognized cadence falls back to daily.
// Raise the per-sweep ceiling on a large multi-tenant site.
add_filter( 'evnm_pro_calendar_sync_batch', function ( $max ) {
return 200;
} );
// Author site-wide (owner_id 0) synced events as a dedicated account.
add_filter( 'evnm_pro_sync_default_author', function ( $user_id ) {
return my_addon_sync_bot_id();
} );
// Observe every sweep.
add_action( 'evnm_pro_calendar_sync', function () {
my_addon_log( 'calendar sync sweep ran' );
}, 20 );
REST Routes
Both controllers register on the shared eventonomy/v1 namespace and return the canonical envelope. Neither one is admin-ajax.
Member self-service - pro/calendar-feeds
Gated on can_manage(): logged in and evnm_create_events (the same capability as frontend event submission). The {id} routes are owner-only on top of that.
| Method | Route | Purpose |
|---|---|---|
GET |
/eventonomy/v1/pro/calendar-feeds |
The current member's feeds. Returns { items, total }. |
POST |
/eventonomy/v1/pro/calendar-feeds |
Create a feed for the current member. |
POST |
/eventonomy/v1/pro/calendar-feeds/scan |
Preview a source before saving. Args: source_type (enum remote-ics | eventbrite, default remote-ics), url, token. |
POST |
/eventonomy/v1/pro/calendar-feeds/{id}/sync |
Run one feed now. |
DELETE |
/eventonomy/v1/pro/calendar-feeds/{id} |
Remove the subscription. Imported events remain, authored by the member. |
The per-member ceiling is filterable:
add_filter( 'evnm_pro_member_feed_cap', function ( int $cap, int $user_id ) {
return user_can( $user_id, 'manage_options' ) ? 200 : $cap; // default 20
}, 10, 2 );
Site owner - pro/admin/calendar-feeds
Every route is gated on manage_options. This is the API entry point of the three-entry-points rule: the Sync Sources admin screen server-renders the list and drives add / scan / sync / remove entirely through these routes.
| Method | Route | Purpose |
|---|---|---|
GET |
/eventonomy/v1/pro/admin/calendar-feeds |
Every feed across all owners, paginated (page, per_page). |
POST |
/eventonomy/v1/pro/admin/calendar-feeds |
Create a site-wide feed (owner_id 0). |
POST |
/eventonomy/v1/pro/admin/calendar-feeds/scan |
Preview a source. |
POST |
/eventonomy/v1/pro/admin/calendar-feeds/{id}/sync |
Run any feed now. |
DELETE |
/eventonomy/v1/pro/admin/calendar-feeds/{id} |
Remove any feed. |
The admin list response carries the full pagination envelope - items, total, page, per_page, pages, has_more, next_cursor - and each item adds owner_id plus a resolved owner label (Site-wide for 0, the display name otherwise, User #{id} when the account no longer resolves). Owner names come from one batched evnm_user_display_names() call for the whole page, never a per-row lookup.
Registering a Sync Source
A source is registered on Free's evnm_import_sources filter - the same registry the CSV and ICS importers use. Pro's remote-ics entry (includes/Providers/CalendarSyncProvider.php:15) is the reference:
add_filter( 'evnm_import_sources', function ( $sources ) {
$sources = is_array( $sources ) ? $sources : array();
$sources['my-remote-feed'] = array(
'label' => __( 'My remote calendar', 'my-addon' ),
'db' => true, // no file upload/staging - reads from config
'remote' => true, // fetched over HTTP rather than uploaded
'source' => static fn() => new My_Remote_Source(), // ImportSourceInterface
'mapper' => static function () {
// Reuse Free's registered `ics` mapper via the registry, never by
// naming the concrete class. Safe to re-apply: not invoked during
// filter build, so there is no recursion.
$registry = apply_filters( 'evnm_import_sources', array() );
return ( ! empty( $registry['ics']['mapper'] ) && is_callable( $registry['ics']['mapper'] ) )
? ( $registry['ics']['mapper'] )()
: null;
},
'detect' => static fn(): int => 0,
);
return $sources;
} );
Once registered, the source id is usable as a feed's source_type and runs through the same FeedRunner path. Registration and execution are covered in full in Extending.
Token Security
Member-connected API tokens (Eventbrite, and the same pattern for future providers) live inside the feed's config JSON. They are encrypted at rest by Eventonomy\Pro\Support\Secret (includes/Support/Secret.php):
- AES-256-GCM, with the key derived as
sha256( 'eventonomy-pro|' . wp_salt( 'auth' ) ). A stolen database row is useless withoutwp-config.php. - Ciphertext carries a versioned
evnmg1:prefix so the format can evolve. encrypt()returns''for empty input or when the openssl AES-GCM path is unavailable; the caller then stores the raw value rather than losing it.decrypt()returns''on any failure - wrong format, tampered tag, rotated salt - so a caller can safely fall back to treating the value as plaintext (for example a preview scan running against a just-pasted token that has not been stored yet).- Decryption happens only when a sync actually runs.
Tokens are never returned to REST or admin output. Both controllers pass rows through a private present() shaper that emits only id, source_type, url, cadence, status, last_run, created (plus owner_id / owner on the admin side). The config array is never serialized wholesale, so there is no path by which the token key reaches a response body. If you extend either controller, preserve that: shape explicitly, never spread $feed['config'].
Site-wide Eventbrite credentials are a separate concern - those live in the evnm_pro_eventbrite_token option and are checked, never echoed, by has_eventbrite_token().
What's Next?
- Hooks & Filters - the
evnm_admin_rail_groupsandevnm_import_sourcesseams this feature is built on. - Extending - registering an import source and running it with
ImportRunnerInterface.