WP Manifestindependent plugin directory
manifest / events / wp-calendar-feed-importer

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.

★ 0stars
2release downloads
v1.0.0latest · 1mo ago

Install

Download calendar-feed-importer.zip From the v1.0.0 release on GitHub, Sep 13, 2026

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.zip
No built-in updater. New versions won't appear under Dashboard → Updates. Install new releases yourself, or let Git Updater track the repository.

Subscribe 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.zip after sh bin/build-zip.sh; pushing a tag such as v1.0.0 builds 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())

  1. Load feed, check adapter availability (fail with a clear error, never fall back to plain posts).
  2. Acquire the per-feed lock (atomic INSERT into wp_options, 15 min stale expiry). Manual runs report "already running".
  3. Conditional fetch (If-None-Match / If-Modified-Since); HTTP 304 = successful no-change run.
  4. Parse with Sabre VObject; expand recurrences inside [now − lookback, now + lookahead]; dedupe by UID/RECURRENCE-ID (highest SEQUENCE, then LAST-MODIFIED/DTSTAMP).
  5. Per occurrence: filters → title prefixes → categories → NormalizedEvent → look up mapping (sha256(feed_id \0 uid \0 recurrence_id)).
  6. Create, or three-way merge (base = last applied snapshot, local = destination, remote = new). Locally edited fields become protected; categories merge additively.
  7. Mark encountered items as seen; only after a complete, fully successful HTTP 200 run increment missing_count for in-window items not seen and apply the (conservative) policy.
  8. 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 (twp for the tests site).
  • If npx wp-env start stops after downloading the plugin zips, bin/dev-env.sh up takes 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)

  1. 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.
  2. 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".
  3. 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.
  4. 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.
  5. Sync now runs immediately; scheduled feeds run through WP-Cron. Set DISABLE_WP_CRON and call wp-cron.php from a server cron for predictable timing (instructions on the Settings screen).
  6. 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).
  7. 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 LOCATION string (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 UID are 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.

Tag
Published
Assets
Downloads
v1.0.0 latest
Sep 13, 2026 1mo ago
calendar-feed-importer.zip
2