Eventonomy

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_feeds table and what each column means
  • The FeedRepositoryInterface contract and how to resolve it
  • How FeedRunner executes one feed against Free's import runner
  • How CalendarSyncSchedulerProvider schedules, 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:

  1. Validates the row. A missing id or empty source_type returns ok => false, message => 'invalid_feed' without touching anything.
  2. Resolves Free's ImportRunnerInterface from the container. If it is unavailable the run returns message => 'no_runner' - Pro never names Free's concrete ImportService.
  3. Builds the import context:
    • user_id / author_id - the feed owner when owner_id > 0 (the member owns their synced events), otherwise apply_filters( 'evnm_pro_sync_default_author', 1 ). user_id is what EventService::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 the remote-ics source get separate dedup and undo namespaces, so removing one feed's events never touches the other's.
    • import_source - "evnm_pro_feed:{feed_id}".
  4. 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.
  5. 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.
  6. Records the outcome on the feed: last_status is ok when the walk finished, syncing mid-walk, error on failure (with a truncated last_error). A mid-walk page deliberately does NOT report ok - a walk that dies at page 3 must not leave the Sync Sources screen claiming a clean sync.
  7. 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-pro group via as_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:

  1. Pulls up to apply_filters( 'evnm_pro_calendar_sync_batch', 50 ) active feeds, oldest run first.
  2. For each, evnm_pro_calendar_feed_is_due( $feed ) decides. A feed with no last_run_utc is 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.
  3. 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 without wp-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_groups and evnm_import_sources seams this feature is built on.
  • Extending - registering an import source and running it with ImportRunnerInterface.