Netstand Events Sync
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/ajin/wp-netstand-events-sync/archive/refs/heads/main.zipSynchronise chess competition fixtures from Netstand club pages (KNSB, HSB and other federations running Netstand) into The Events Calendar or Eventin — with per-subscription schedules, title formats, per-day grouping, timezone, missing-event policy and manual-edit protection. GPL-2.0-or-later.
Works with The Events Calendar and Eventin. Not affiliated with Netstand, any chess federation, or either calendar project.
Install
Download netstand-events-sync.zip from the latest release and upload it under Plugins → Add New → Upload. 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/netstand-events-sync (the repository is named wp-netstand-events-sync, the plugin folder should be netstand-events-sync); there are no dependencies to install.
- Administrator documentation:
readme.txt - Changelog:
CHANGELOG.md - Security model and controls:
SECURITY.md· Contributing:CONTRIBUTING.md· Third-party notices:THIRD-PARTY-NOTICES.md - Release ZIP:
build/netstand-events-sync.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 (wp-env containers and php:8.2-cli) |
Requires 8.1+; CI runs 8.1, 8.2 and 8.3 |
| The Events Calendar | 6.17.4.1 | ORM tribe_events()->set_args()->create() / save(), legacy tribe_create_event() fallback |
| Eventin (wp-event-solution) | 4.1.24 | Etn\Core\Event\Event_Model::create()/update(), plain-meta fallback |
| Netstand | 4.0 (knsb.netstand.nl, hsb.netstand.nl) | Club page team table; team page fixtures table (Ronde / Datum / Thuis / Uit / Uitslag) |
1. Installation
- Install and activate The Events Calendar or Eventin (both may be active; pick one under Settings → Calendar plugin).
- Upload
netstand-events-sync.zipfrom the releases page via Plugins → Add New → Upload (or build it withsh bin/build-zip.sh). - Activate Netstand Events Sync.
The plugin lives under Tools → Netstand Events Sync in wp-admin, independent of which calendar plugin is active. Without a supported calendar plugin it stays active but shows a warning and disables synchronisation.
2. Configuration
Go to Tools → Netstand Events Sync.
| Field | Meaning |
|---|---|
| Subscription name | Label shown in the admin and in the event description heading (e.g. KNSB). |
| Club URL | A Netstand club page, https://{host}/clubs/view/{id}. The host and base URL are derived from it. |
| Event category | Category (in the active calendar plugin) assigned to imported events. Created automatically when missing. |
| Event title prefix | Optional. KNSB: gives KNSB: DSC Delft 1 – Veenendaal 1; blank gives DSC Delft 1 – Veenendaal 1. |
| Event grouping | One event per match (default) or One event per day: all teams playing on a date become a single event. |
| Title format (per day) | Shown for per-day grouping. Nineteen presets rendered as examples: club + team numbers with or without the round (KNSB: DSC Delft 1, 3, 5 (Ronde 3), … (R3)), round first (KNSB: Ronde 3 · DSC Delft 1, 3, 5), "Teams 1, 3, 5" wording, match counts (KNSB: 3 wedstrijden (Ronde 3)), match-day wording, full team names, numbers only; or a custom template with {prefix} {club} {teams} {team_numbers} {count} {round} {season} {date} {subscription}. |
| Title format | Dropdown of ready-made formats shown as example titles (teams only, with round, round first, with class and round, class first) plus Custom… for a free template using {prefix} {home_team} {away_team} {round} {class} {season} {subscription}. |
| Default home match time | Per-match grouping only. Used when Netstand shows no time and the club team plays at home; away fixtures become all-day. |
| Default start time | Per-day grouping only. Used for a day event when Netstand shows no time. Blank by default, which creates all-day events. |
| Timezone | Timezone of the Netstand times and of the imported events (default Europe/Amsterdam). |
| Enabled | Include in automatic and Sync all runs. |
| Schedule | Manual only, daily, twice daily or weekly (default). A WP-Cron tick runs hourly and syncs each enabled subscription whose interval has elapsed since its last run. |
| Import window | Look back / look ahead in days (default 365/365, i.e. the whole season). Fixtures outside the window are neither imported nor evaluated as missing. |
| New-event status | Published (default), Draft, Pending review or Private for newly created events. |
| Missing-event policy | Keep unchanged, Move to Draft (default) or Move to Trash, after N consecutive successful syncs (default 2). Nothing is deleted permanently; a fixture that reappears is restored. |
Test connection sits under the club URL and works before the subscription is saved: it fetches the club page with the values in the form, checks Netstand compatibility and shows host, club name, club ID, the discovered teams and a few sample events rendered with your prefix, format and grouping (from the first team, or the first three teams in per-day mode so combined titles show), all without leaving the page or writing anything.
Preview (button on the edit page and a row action in the list) is a dry run: it fetches and parses exactly like a sync and lists what a sync would do per event (create, update with the current and new title, unchanged, missing, skipped) with filter buttons, without writing anything or touching counters. Sync now runs the real thing.
Global settings: Calendar plugin (auto-detect, The Events Calendar, or Eventin) and Automatic synchronization (default on), the master switch for the hourly WP-Cron tick (netstand_events_sync_tick) that runs each subscription on its own schedule. Manual Sync and Sync all are always available.
Sample configuration (KNSB)
Subscription name: KNSB
Club URL: https://knsb.netstand.nl/clubs/view/112060
Event category: KNSB-competitie
Event title prefix: KNSB:
Default home match time: 13:00
Enabled: Yes
A second subscription for another host needs no code change:
Subscription name: HSB
Club URL: https://hsb.netstand.nl/clubs/view/32644
Event category: HSB-competitie
Event title prefix: HSB:
Default home match time: 20:00
2a. Architecture: calendar adapters
Netstand
↓ Netstand_Client + Club_Parser / Team_Parser
Generic normalised fixture
↓ Subscription_Sync (identity, hashing, create/update/unchanged, missing-fixture safety)
Calendar_Adapter_Interface
├── Adapters\TEC_Adapter The Events Calendar (post type tribe_events, taxonomy tribe_events_cat)
├── Adapters\Eventin_Adapter Eventin (post type etn, taxonomy etn_category)
└── future adapters via the `netstand_events_sync_adapters` filter
The core never references a calendar plugin. Subscription_Sync receives an object implementing Calendar_Adapter_Interface and only calls find_by_source_key, create, update, update_meta, set_status, get_unseen_future_events and count_managed. Abstract_Post_Adapter implements all of that generically for post-based calendars (ownership meta, lookups, status changes, category terms); a concrete adapter supplies the post type, taxonomy, start-date meta key and the two plugin-specific operations create_event / update_event.
Adapter_Registry lists the built-in adapters in priority order (TEC, Eventin), knows which are available, and resolves the active one from the Calendar plugin setting (auto = first available). Managed events record which adapter created them in _netstand_calendar. Switching calendars does not move events: the old calendar's events are left untouched (they are no longer found because lookups are per post type) and the next sync creates fresh events in the new calendar. Every sync result and history row names the calendar it wrote to, and the dashboard shows a warning whenever imported events exist in an available calendar that is not the active one, since those events are not updated by syncs.
Adding an adapter
add_filter( 'netstand_events_sync_adapters', function ( array $adapters ) {
$adapters[] = new My_Calendar_Adapter(); // extends NetstandSync\Abstract_Post_Adapter
return $adapters;
} );
Implement get_id, get_label, is_available, get_post_type, get_taxonomy, start_date_meta_key, start_date_meta_type, create_event( array $args ) and update_event( int $id, array $args ). $args contains title, content, status, start_date/end_date (Y-m-d H:i:s, site-local), all_day, timezone, url, category.
Eventin specifics
- Events are
etnposts withetn_start_date/etn_end_date(Y-m-d),etn_start_time/etn_end_time(H:i),event_timezoneandevent_type = offline. Eventin has no all-day flag, so all-day fixtures run 00:00–23:59. Etn\Core\Event\Event_Modelis used when present; otherwise the same meta is written with core WordPress functions.- Eventin only accepts IANA timezone identifiers; the subscription timezone is always one, so
event_timezoneis set explicitly. - The Netstand page is stored in Eventin's
external_link, which Eventin renders as a "More Info" button (new tab) on the single event page, and is also linked in the description.
3. How a subscription behaves
Club URL → club page → discover /teams/view/ links → fetch each team page once
→ parse fixtures table → normalise → compare with managed TEC events
→ create / update / unchanged → evaluate missing fixtures → save summary
- Each subscription is synchronised independently; one failing subscription never blocks another.
- Every team page is fetched at most once per run.
- Events are created as normal events of the active calendar plugin with the subscription's category, the Netstand page as event website (TEC's Event Website / Eventin's External Link: the pairing page, else the round page, else the team page), and a description containing subscription/season, class, round, both teams, the result (when known) and a "Bekijk op Netstand" link.
- Date/time: Netstand time wins if present. Otherwise home fixtures get the subscription's default home time (default duration 5 hours, filter
netstand_events_sync_event_duration_minutes); away fixtures without a time become all-day events. Times are interpreted and stored in the subscription's timezone (default Europe/Amsterdam), independent of the WordPress site timezone. - Season: derived from the fixture date (July–December →
YEAR-(YEAR+1), January–June →(YEAR-1)-YEAR), since team pages do not expose it.
4. Duplicate prevention
Every fixture gets a deterministic source key, stored as _netstand_source_key:
netstand:{host}:{club_id}:pairing:{pairing_id} (preferred; Netstand pairing IDs are stable)
netstand:{host}:{club_id}:{season}:{team_id}:round:{round} (fallback without pairing link)
Lookups match _netstand_managed = 1 and _netstand_subscription_id and _netstand_source_key, so the same fixture always resolves to the same event and subscriptions are isolated from each other. The title never takes part in identity, so prefix changes cannot create duplicates. A pairing listed under two teams of the same club is processed once per run.
Change detection uses a SHA-256 hash (_netstand_source_hash) over date, time, teams, class, round, season, result, pairing/round/team URLs and the subscription's prefix, category, home time and name. Same hash → only the "last seen" markers are touched; different hash → the existing event is updated in place.
5. Missing-fixture safety
- Every run has a unique sync ID; every fixture seen gets
_netstand_last_seen = sync_id. - Only after a fully successful run (club page parsed, every team page parsed, no write failures) are managed future events of that subscription that were not seen examined:
_netstand_missing_countis incremented. - After the subscription's missing-event policy threshold (default 2 consecutive misses) the event is moved to draft or trash as configured (never deleted permanently) and flagged
_netstand_drafted_by_sync; policy Keep never touches it. If the fixture reappears, the counter resets and the event is restored (untrashed if needed) with the subscription's new-event status. Events an administrator trashed themselves are left alone. - HTTP errors, login redirects, empty bodies, incompatible HTML, zero teams, a single failing team page or a write failure make the run failed or partial; no counters are incremented.
- Historical events (start date before today) and events beyond the look-ahead window are never drafted or trashed.
- Events an administrator moved to the trash are left alone and not recreated.
6. Title prefix and format behaviour
Titles are built by Event_Title_Formatter from the subscription's Title format (default {prefix} {home_team} – {away_team}, en dash) and its prefix. The form offers presets rendered as example titles, e.g. KNSB: DSC Delft 1 – Veenendaal 1 (R1) or KNSB: R1 · DSC Delft 1 – Veenendaal 1, and a custom template. Empty tokens are tidied (a fixture without a class under ({class}, R{round}) renders as (R1)). The format is part of the change hash, so switching it rewrites every managed title of that subscription in place. The prefix belongs to the subscription. Changing it from KNSB: to KNSB Competitie: updates every managed event of that subscription on the next sync (same post IDs); clearing it removes the prefix.
6a. Manual edits on imported events
Imported events are normal events and editors may change them. A resync only rewrites what it owns:
| Field | On resync |
|---|---|
| Date, time, all-day, Netstand link | Always taken from Netstand (authoritative). |
| Title | Regenerated, unless an editor changed it: the plugin remembers the title it last wrote and keeps a title that differs. |
| Description | The generated block sits between and. Only that block is refreshed (e.g. when a result comes in); text added before or after it is kept. If the markers are removed, the description is never touched again. Descriptions written by versions without markers are recognised by their fixed shape and migrated to markers on their next update, keeping surrounding text. |
| Categories | The subscription category is added alongside existing ones. Changing the subscription category swaps only the plugin's previous category; categories added by editors stay. |
| Other fields (venue, organiser, featured image, tags, custom meta) | Never written by the plugin. |
| Status | Only changed by the missing-fixture logic (draft) and its reversal (publish). |
Unchanged fixtures are never rewritten at all, so edits survive every sync until Netstand data or the subscription settings change. The sync summary reports how many events had manual edits preserved.
6b. One event per day
With grouping set to per day, the sync combines the fixtures of a subscription that share a date and a round into one event. If teams play different rounds on the same date (e.g. a postponed match), that date gets one event per round:
- Identity:
netstand:{host}:{club_id}:day:{date}:round:{round}, so the event is stable even when teams are added to or removed from that date/round. Day events created by earlier versions under a date-only key are adopted and re-keyed on their next sync. - Title from the day format, e.g.
KNSB: DSC Delft 1, 3, 5. Team numbers are derived by stripping the club name from the team name (DSC Delft 3→3,DSC 9 (jeugd)→9 (jeugd)); unrecognised names are shown in full. - Start time: the earliest explicit Netstand time; otherwise the subscription's default start time for day events; otherwise all-day. The home match time does not apply to day events.
- Description: heading with subscription and season, "Ronde N · 3 wedstrijden", then one line per match: the match (club team in bold) linking to its Netstand pairing page, followed by home/away, time, class, round and result, and a single "Bekijk op Netstand" link to the club page at the bottom.
- Change detection covers every match on that day: a new result, a moved opponent or a changed team list updates the day event in place.
- Switching grouping is treated as a deliberate change: on the next successful sync every event of the previous mode (past and future) is moved to draft immediately and reported in the sync result. Nothing is deleted; delete the drafts yourself if you no longer want them. Switching back republishes them.
7. Event metadata
All managed events carry: _netstand_managed, _netstand_calendar, _netstand_kind (fixture/day), _netstand_team_ids, _netstand_fixture_count, _netstand_round_url, _netstand_title (last title written), _netstand_category_term, _netstand_subscription_id, _netstand_source_key, _netstand_host, _netstand_club_id, _netstand_club_name, _netstand_team_id, _netstand_team_name, _netstand_class, _netstand_round, _netstand_season, _netstand_home_team, _netstand_away_team, _netstand_is_home, _netstand_pairing_id, _netstand_url, _netstand_result, _netstand_last_seen, _netstand_last_sync, _netstand_source_hash, _netstand_missing_count, _netstand_drafted_by_sync.
8. Netstand parser assumptions
Verified against knsb.netstand.nl and hsb.netstand.nl (Netstand 4):
- Club page:
<h1>Vereniging {club}</h1>(the "Vereniging"/"Club"/"Verein" prefix is stripped) and a table withNaam/Klasseheaders whose rows link to/teams/view/{id}and/divisions/view/{id}. - Team page:
<h1>{team name}</h1>, a headingWedstrijden ({class})linking to/divisions/view/, and a table with headersRonde,Datum,Thuis,Uit,Uitslag. Columns are matched by header text (English and a few Dutch variants accepted), never by position. An optionalTijd/Aanvangcolumn or a time inside the date cell is read. - Dates are
dd-mm-yyyy(alsoyyyy-mm-dd,dd/mm/yyyy). Results look like<b>3½</b> - 4½(bold marks the viewed team) and are stored as3½ - 4½;-means not played. - Home/away is decided by the team ID in the Thuis/Uit link (team names differ from club names, e.g.
DSC 1vsDSC Delft), with a name comparison as fallback. - Compatibility requires at least two signals among:
/teams/view/links,/clubs/view/or/divisions/view/links, "Netstand" in title/brand/footer,Naam+Klasseheaders, a "Teams" heading. - Protected pages redirect to
/users/login; this is detected and reported.
9. Known limitations
- Netstand team pages show no start times; times therefore come from the subscription's default home time (home) or events are all-day (away).
- The season is derived from dates, not read from the division page (which would need one extra request per division).
- Venues and organisers are not created or mapped.
- Only the fixtures table is imported: no standings, board results, players or line-ups.
- WP-Cron depends on site traffic unless a real cron triggers
wp-cron.php; schedules are checked hourly, so a daily schedule runs within an hour of its due time. - A trashed managed event is not recreated; restore it from the trash to resume updates.
- Synchronisation runs synchronously in the request; with a very large club (dozens of teams) a manual sync can take some seconds.
10. Test checklist
Automated (no WordPress needed, run in any PHP 8.1+):
php tests/run-tests.php # or: docker run --rm -v "$PWD":/app -w /app php:8.2-cli php tests/run-tests.php
Covers: club identification and ID parsing, team discovery (KNSB and HSB), team ID / class / round / date / time / home-away / pairing URL & ID / result extraction, relative URL resolution, season detection, title prefix (configured, empty, changed, removed), content formatting, source keys and hashes, idempotent second sync, date/opponent/result/category/home-time changes updating the same event, manual events untouched, manual edits (title, description, categories) preserved on resync, subscription isolation, failed and partial syncs not marking events missing, missing counter and drafting after two successful syncs, reappearing fixtures republished, historical events never drafted, trashed events left alone, each team page fetched once.
Manual, on a WordPress site with The Events Calendar or Eventin:
- Activate without a calendar plugin: warning shown, menu under Settings, no fatal error.
- Add the KNSB subscription, Test connection shows host
knsb.netstand.nl, clubDSC Delft, ID112060, 7 teams. - Sync: created count equals the number of fixtures; events appear in the calendar with category and prefix.
- Sync again: 0 created, 0 updated, all unchanged.
- Change the prefix, sync: titles updated, no new events.
- Clear the prefix, sync: prefixes removed.
- Add an HSB subscription and sync: independent events with their own prefix/category.
- Temporarily set an invalid club URL and sync: status Failed, no events drafted.
- Create a manual event; sync twice; it is unchanged.
- Disable automatic sync: cron run does nothing. Re-enable: next run date shown.
- Delete a subscription: events remain.
10a. Local WordPress environment (wp-env + WP-CLI)
The repository root contains a wp-env configuration that boots WordPress with this repository mounted as a plugin and The Events Calendar and Eventin installed, so both adapters can be exercised with WP-CLI. Inside the containers the plugin folder is named after the repository directory.
npm install # installs @wordpress/env
sh bin/dev-env.sh up # starts wp-env, installs TEC + Eventin, activates everything (admin / password)
npm test # unit + engine suite in php:8.2-cli
npm run smoke # end-to-end smoke test + admin render test on the dev site
npm run build # build/netstand-events-sync.zip
bin/smoke-test.php creates the KNSB subscription, syncs twice (idempotency), verifies events and changes/removes/restores the prefix and title format; bin/admin-render-test.php renders every admin screen as an administrator and runs "Test connection" for KNSB and HSB.
Notes:
.wp-env.jsonpins the versioned TEC download (the-events-calendar.6.17.4.1.zip). The unversionedthe-events-calendar.zipon downloads.wordpress.org currently returns a 10 KB stub containing only a readme, which makes wp-env report "plugin could not be found".- If
wp-env startexits without reaching "WordPress development site started",sh bin/dev-env.sh upfinishes the job: it brings the stack up from the generated compose file, downloads the versioned TEC and Eventin packages into the environment directory and activates them. - Ports 8890/8891 are used so the environment does not collide with another wp-env project on 8888.
11. File tree
.
├── netstand-events-sync.php bootstrap, constants, activation hooks
├── uninstall.php removes options + cron (keeps events)
├── readme.txt WordPress.org style readme
├── README.md · CHANGELOG.md · SECURITY.md · CONTRIBUTING.md · THIRD-PARTY-NOTICES.md · LICENSE
├── .github/workflows/ CI (lint, tests, package) and release (ZIP attached to tags)
├── .distignore files excluded from the release ZIP
├── assets/admin.css
├── assets/admin.js live title-format previews in the subscription form
├── bin/build-zip.sh builds build/netstand-events-sync.zip
├── bin/dev-env.sh wp-env helper: up / smoke / down
├── bin/smoke-test.php WP-CLI end-to-end smoke test (wp eval-file)
├── bin/admin-render-test.php WP-CLI admin-screen render test
├── includes/
│ ├── autoload.php NetstandSync\ autoloader
│ ├── class-plugin.php bootstrap, TEC dependency check
│ ├── class-admin.php subscriptions UI, test connection, manual sync, settings, history
│ ├── class-subscription.php subscription value object + validation
│ ├── class-subscription-repository.php option storage
│ ├── class-settings.php global settings (auto sync)
│ ├── class-sync-manager.php runs one/all subscriptions (cron + manual)
│ ├── class-subscription-sync.php the synchronisation engine (WordPress-free)
│ ├── class-netstand-client.php wp_remote_get wrapper (timeout, UA, retry, cache)
│ ├── interface-netstand-client.php
│ ├── class-club-parser.php club HTML → club identity + teams
│ ├── class-team-parser.php team HTML → normalised fixtures
│ ├── class-html-document.php DOMDocument/XPath helper
│ ├── class-url-resolver.php base URL / host / relative link resolution / IDs
│ ├── class-season-helper.php
│ ├── class-fixture-identity.php source keys + change hashes
│ ├── class-day-grouper.php combines same-day fixtures into one event
│ ├── class-manual-edit-guard.php keeps editors' title/description changes on resync
│ ├── class-event-title-formatter.php
│ ├── class-event-content-formatter.php
│ ├── interface-calendar-adapter.php contract every calendar adapter implements
│ ├── class-abstract-post-adapter.php shared meta/lookup/status logic for post-based calendars
│ ├── class-adapter-registry.php built-in adapters, availability, active selection
│ ├── adapters/class-tec-adapter.php The Events Calendar (ORM + legacy API)
│ ├── adapters/class-eventin-adapter.php Eventin (Event_Model + meta fallback)
│ ├── class-scheduler.php WP-Cron weekly hook
│ ├── class-logger.php sync history (last 50)
│ ├── class-debug-store.php WP_DEBUG-only HTML retention
│ └── class-parse-error.php WordPress-free error object
└── tests/
├── run-tests.php dependency-free runner
├── fixtures/ real Netstand HTML (KNSB + HSB, players anonymised)
├── support/ WP stubs, fake client, in-memory event repository
├── unit/test-parsers.php
├── unit/test-adapters.php
└── integration/test-sync.php
11a. Security
See SECURITY.md for the trust model and the controls against SSRF, XSS, CSRF and resource exhaustion. In short: https-only fetches through wp_safe_remote_get() with same-host redirects and size/time limits, untrusted HTML reduced to text, capability + nonce + POST checks on every admin action, and hard caps on teams, fixtures and log size.
12. Hooks
| Hook | Purpose |
|---|---|
netstand_events_sync_capability (filter) |
Capability for the admin screen, default manage_options. |
netstand_events_sync_http_args (filter) |
Arguments passed to wp_remote_get(). |
netstand_events_sync_client (filter) |
Replace the HTTP client. |
netstand_events_sync_event_duration_minutes (filter) |
Duration for timed fixtures, default 300. |
netstand_events_sync_event_status (filter) |
Post status for new events, default the subscription's New-event status. |
netstand_events_sync_first_sync_timestamp (filter) |
First scheduler tick after activation. |
netstand_events_sync_debug_store_enabled (filter) |
Retain downloaded HTML (default WP_DEBUG). |
netstand_events_sync_after_sync (action) |
Fires with the summary after each subscription sync. |
netstand_events_sync_adapters (filter) |
Register additional calendar adapters. |
netstand_events_sync_allow_http (filter) |
Accept http:// club URLs (default false; https only). For local testing. |