Calendar Feed Importer
Subscribe to iCalendar (ICS) feeds and import their events into The Events Calendar or Eventin, with per-feed schedules, filters, title prefixes, categories and manual-edit protection.
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/ajin/wp-calendar-feed-importer/releases/download/v1.0.0/calendar-feed-importer.zipSubscribe to iCalendar (ICS) feeds and import their events into The Events Calendar or Eventin — with per-feed schedules, filters, title prefixes, categories, and a manual-edit-safe three-way merge. GPL-2.0-or-later.
Works with The Events Calendar and Eventin. Not affiliated with either project.
Install
Download calendar-feed-importer.zip from the latest release and upload it under Plugins → Add New → Upload. Do not use GitHub's "Code → Download ZIP": that is the source tree without the bundled libraries and the plugin will report them as missing. Requires WordPress 6.4+, PHP 8.1+, and The Events Calendar 6.x and/or Eventin 4.x. Cloning this repository also works: clone it as wp-content/plugins/calendar-feed-importer (the repository is named wp-calendar-feed-importer, the plugin folder must be calendar-feed-importer) and run composer install --no-dev.
- Administrator documentation:
readme.txt - Changelog:
CHANGELOG.md - Security model and controls:
SECURITY.md· Contributing:CONTRIBUTING.md· Third-party notices:THIRD-PARTY-NOTICES.md - Configuration examples:
docs/examples/(one TEC feed, one Eventin feed, a WP-CLI script that creates them) - Release ZIP:
build/calendar-feed-importer.zipaftersh bin/build-zip.sh; pushing a tag such asv1.0.0builds and attaches it to a GitHub release
Compatibility matrix (tested)
| Component | Version tested | Notes |
|---|---|---|
| WordPress | 6.9 (wp-env core: null, latest) |
Requires 6.4+ |
| PHP | 8.2.33 (wp-env containers); composer.json platform 8.1 |
Requires 8.1+ |
| The Events Calendar | 6.17.4.1 | ORM tribe_events()->set_args()->create(), ORM save() verified by re-reading, Tribe__Events__API::updateEvent() fallback; venues via tribe_venues() |
| Eventin (wp-event-solution) | 4.1.24 | Etn\Core\Event\Event_Model::create()/update(); 4.0 ≤ version < 5.0 accepted, other versions refused with a compatibility error |
| sabre/vobject | 4.6.1 | prefixed as CFI_Vendor\Sabre\... in the release ZIP |
| MariaDB | mariadb:lts (wp-env) |
tables created with dbDelta() |
Architecture
src/
├── Plugin.php service container + hook registration (cron, admin, CLI)
├── Activation.php tables, capability, cron on activate; cron/locks cleared on deactivate
├── Domain/ immutable value objects: Feed, Rule, RemoteEvent, NormalizedEvent, MergeResult, SyncResult
├── Fetch/ FeedFetcher (wp_safe_remote_get, ETag/Last-Modified, limits) behind HttpTransport
├── Ical/ IcalParser interface, SabreIcalParser (bounded recurrence expansion), EventNormalizer
├── Rules/ Matcher (shared), FilterEngine, TitleTransformer, CategoryResolver (+TermResolver)
├── Sync/ SyncService pipeline, ThreeWayMerger, CategoryMerger, MissingEventProcessor,
│ SyncLock (atomic options-table insert), ItemActions, EditDetector
├── Adapter/ CalendarAdapter contract, AdapterRegistry, TheEventsCalendarAdapter, EventinAdapter
├── Repository/ Schema (dbDelta), FeedRepository, ImportedItemRepository, LogRepository
├── Scheduling/CronScheduler per-feed WP-Cron events, continuation jobs, daily maintenance
├── Admin/ feed list/editor, preview, imported events, logs, settings, event meta box
└── Cli/SyncCommand.php wp calendar-feed ...
Everything vendor-specific (post types, meta keys, date formats, venues/locations, taxonomies) lives in the two adapters. Cron, the admin "Sync now" action, Preview (dry run) and WP-CLI all call the same SyncService.
Pipeline (SyncService::run())
- Load feed, check adapter availability (fail with a clear error, never fall back to plain posts).
- Acquire the per-feed lock (atomic
INSERTintowp_options, 15 min stale expiry). Manual runs report "already running". - Conditional fetch (
If-None-Match/If-Modified-Since); HTTP 304 = successful no-change run. - Parse with Sabre VObject; expand recurrences inside
[now − lookback, now + lookahead]; dedupe byUID/RECURRENCE-ID(highestSEQUENCE, thenLAST-MODIFIED/DTSTAMP). - Per occurrence: filters → title prefixes → categories →
NormalizedEvent→ look up mapping (sha256(feed_id \0 uid \0 recurrence_id)). - Create, or three-way merge (base = last applied snapshot, local = destination, remote = new). Locally edited fields become protected; categories merge additively.
- Mark encountered items as seen; only after a complete, fully successful HTTP 200 run increment
missing_countfor in-window items not seen and apply the (conservative) policy. - Store validators (ETag/Last-Modified) only after a fully successful run; release the lock in
finally.
Large feeds are processed in batches (cfi_batch_size, default 250; cfi_time_budget, 20 s for web requests, unlimited for cron/CLI). The remainder continues in a WP-Cron job under the same run ID.
Canonical managed fields
title, description, excerpt, start, end, all_day, timezone, location, event_url, status, categories. Dates are compared as Y-m-d H:i in the event timezone; all-day events use 00:00 and the inclusive last day 23:59 (ICS DTEND is exclusive and converted). status is written on creation only.
Hooks
AJAX: cfi_rule_preview and cfi_test_connection (capability + nonce; evaluate the unsaved form, never write).
Filters: cfi_feed_request_args, cfi_parsed_remote_event, cfi_should_import_event, cfi_transformed_event, cfi_resolved_categories, cfi_merge_decision, cfi_http_timeout, cfi_http_max_bytes, cfi_batch_size, cfi_time_budget, cfi_lock_ttl, cfi_log_retention_days, cfi_preview_limit, cfi_max_occurrences, cfi_max_occurrences_per_event.
Actions: cfi_register_adapters, cfi_before_event_create, cfi_after_event_create, cfi_before_event_update, cfi_after_event_update, cfi_sync_completed. Arguments are documented in the docblocks next to each hook.
Development
Requirements: Docker Desktop, Node (for @wordpress/env). No local PHP needed.
npm install
docker run --rm -v "$PWD":/app -w /app composer:2 composer install
npx wp-env start # generates ~/.wp-env/<hash>; on some machines it exits before configuring WordPress
sh bin/dev-env.sh up # builds/starts the compose stack and installs both sites (admin / password)
The repository root is mounted as wp-content/plugins/calendar-feed-importer in both sites (see .wp-env.json); development files are excluded from the release by .distignore.
- Dev site: http://localhost:8892 — tests site: http://localhost:8893
sh bin/dev-env.sh wp <args>runs WP-CLI on the dev site (twpfor the tests site).- If
npx wp-env startstops after downloading the plugin zips,bin/dev-env.sh uptakes over: it moves the extracted plugins into place (re-extract the zips in~/.wp-env/<hash>/if the folders are incomplete), starts the containers and installs WordPress with TEC, Eventin and this plugin active.
Tests
# Unit suite (pure PHP: parser fixtures, rules, merger, fetcher) — no WordPress needed
docker run --rm -v "$PWD":/app -w /app php:8.2-cli vendor/bin/phpunit --testsuite unit
# Integration suite (WordPress test framework + TEC + Eventin, inside the tests container)
sh bin/dev-env.sh phpunit
# Coding standards (WordPress Coding Standards 3.x + PHPCompatibilityWP)
docker run --rm -v "$PWD":/app -w /app php:8.2-cli vendor/bin/phpcs
# Admin screens rendered as an administrator, checked for PHP notices
sh bin/dev-env.sh wp eval-file wp-content/plugins/calendar-feed-importer/bin/admin-render-test.php
Latest results (2026-09-13, WordPress 6.9 / PHP 8.2.33 / TEC 6.17.4.1 / Eventin 4.1.24):
| Suite | Result |
|---|---|
| Unit | OK (60 tests, 187 assertions) |
| Integration | OK (61 tests, 434 assertions) — adapter contract, hostile-content and editor-feedback tests run against both TEC and Eventin |
| PHPCS | 0 errors, 0 warnings |
| Admin render | all 7 screens + diagnostics without PHP notices; debug.log empty with WP_DEBUG |
| WP-CLI | list, test, preview, sync --all, sync --dry-run, sync --force, unlock --use-remote, detach exercised on the dev site |
| Release ZIP | installed on the tests site as a separate copy (source plugin deactivated), sync created 3 TEC events through the prefixed CFI_Vendor\Sabre code; uninstall.php with the delete option dropped the plugin tables, options and capability while the events stayed |
Release build
sh bin/build-zip.sh # → build/calendar-feed-importer.zip
The build installs production dependencies (composer image), prefixes third-party namespaces with PHP-Scoper 0.18 (CFI_Vendor\) under PHP 8.2 (php-scoper's parser fails silently on the newer PHP in the composer image, so the script runs that step in php:8.2-cli and asserts the prefix), regenerates a classmap-authoritative autoloader and zips the plugin without tests, dev configuration or build scripts. The ZIP installs on a clean WordPress without Composer.
Administrator guide (short)
- Calendar Feeds → Add New. Name, ICS URL (
https://…, no credentials), destination, default timezone. Test connection sits under the URL and works before the feed is saved: it fetches and parses with the values in the form and reports bytes, occurrences inside the window, skipped components and warnings. - Synchronization. Schedule, look back/ahead window, new-event status (Draft by default), missing-event policy (Keep by default; Draft/Trash after N successful syncs), cancellation policy, "lock by default".
- Filters / Title rules / Categories. Ordered rules with a shared matcher. While you edit, the editor reads the feed (cached for 15 minutes, "Refresh from feed" to re-read) and shows next to every rule how many events it matches with example titles, a summary of how many events would be imported, and a live example of each title prefix with every space made visible (the prefix is used exactly as typed; nothing is added). Category names found in the feed appear as one-click chips for the mapping table. Regular expressions are available under the Advanced toggle, validated on save, max 500 characters; an invalid pattern is flagged on its own row.
- Test connection fetches and parses without writing. Preview shows up to 50 occurrences with the reason for inclusion/exclusion, the transformed title, categories and whether the sync would create/update/protect.
- Sync now runs immediately; scheduled feeds run through WP-Cron. Set
DISABLE_WP_CRONand callwp-cron.phpfrom a server cron for predictable timing (instructions on the Settings screen). - Imported events (menu, and a box on each imported event): protected fields, latest remote values, Lock entire event, Allow feed to update field again, Use remote value now, Detach from feed (kept as a tombstone so the remote event is not re-imported; Allow re-import removes the tombstone).
- Settings. Destination status, cron instructions, "Delete plugin data on uninstall" (off by default), redacted diagnostics download, bulk detach (optionally trash) per feed.
Known limitations (1.0)
- Recurring events are flattened into individual destination events; native TEC/Eventin series are not created.
- Organizers are parsed and available to rules but not written to the destinations (TEC/Eventin organizers are linked posts/users).
- TEC venues are created from the whole
LOCATIONstring (no address parsing); Eventin stores the string as the location address. - Eventin has no all-day flag: all-day events are stored as 12:00 AM–11:59 PM with
_cfi_all_day = 1. - Occurrences are rendered in the feed's default timezone (absolute instants preserved).
- Feeds larger than 5 MB or slower than 15 s are rejected (filters
cfi_http_max_bytes,cfi_http_timeout). - Events without
UIDare skipped (listed in Preview and logs). - No CalDAV write-back, OAuth calendars, attachments/images, tickets/RSVP, geocoding, cross-feed deduplication, or multisite network sharing.
- Eventin's own React editor does not show WordPress meta boxes; use Calendar Feeds → Imported Events for the per-event controls there (the box appears on The Events Calendar edit screens).
Releases
1 release. Each count is every asset in that release; expand a row for the breakdown.