WP Manifestindependent plugin directory
manifest / events / wp-netstand-events-sync

Netstand Events Sync

by Ajin · github.com/ajin/wp-netstand-events-sync · website

0stars
0forks

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.zip

Synchronise 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.

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

  1. Install and activate The Events Calendar or Eventin (both may be active; pick one under Settings → Calendar plugin).
  2. Upload netstand-events-sync.zip from the releases page via Plugins → Add New → Upload (or build it with sh bin/build-zip.sh).
  3. 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 etn posts with etn_start_date/etn_end_date (Y-m-d), etn_start_time/etn_end_time (H:i), event_timezone and event_type = offline. Eventin has no all-day flag, so all-day fixtures run 00:00–23:59.
  • Etn\Core\Event\Event_Model is 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_timezone is 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_count is 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 33, 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 with Naam / Klasse headers whose rows link to /teams/view/{id} and /divisions/view/{id}.
  • Team page: <h1>{team name}</h1>, a heading Wedstrijden ({class}) linking to /divisions/view/, and a table with headers Ronde, Datum, Thuis, Uit, Uitslag. Columns are matched by header text (English and a few Dutch variants accepted), never by position. An optional Tijd/Aanvang column or a time inside the date cell is read.
  • Dates are dd-mm-yyyy (also yyyy-mm-dd, dd/mm/yyyy). Results look like <b>3½</b> - 4½ (bold marks the viewed team) and are stored as 3½ - 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 1 vs DSC 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+Klasse headers, 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:

  1. Activate without a calendar plugin: warning shown, menu under Settings, no fatal error.
  2. Add the KNSB subscription, Test connection shows host knsb.netstand.nl, club DSC Delft, ID 112060, 7 teams.
  3. Sync: created count equals the number of fixtures; events appear in the calendar with category and prefix.
  4. Sync again: 0 created, 0 updated, all unchanged.
  5. Change the prefix, sync: titles updated, no new events.
  6. Clear the prefix, sync: prefixes removed.
  7. Add an HSB subscription and sync: independent events with their own prefix/category.
  8. Temporarily set an invalid club URL and sync: status Failed, no events drafted.
  9. Create a manual event; sync twice; it is unchanged.
  10. Disable automatic sync: cron run does nothing. Re-enable: next run date shown.
  11. 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.json pins the versioned TEC download (the-events-calendar.6.17.4.1.zip). The unversioned the-events-calendar.zip on 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 start exits without reaching "WordPress development site started", sh bin/dev-env.sh up finishes 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.