Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions .github/scripts/sync-meetup-events.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,13 @@ function eventsFromIcal(calendar) {
return { events, groupName };
}

function date(value, field, url) {
const match = value?.match(/^(\d{4})(\d{2})(\d{2})/);
if (!match) throw new Error(`Meetup event ${url} has no valid ${field}.`);
return `${match[1]}-${match[2]}-${match[3]}`;
function eventTime(value, field, url) {
if (typeof value !== 'string') throw new Error(`Meetup event ${url} has no valid ${field}.`);
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;

const time = new Date(value);
if (Number.isNaN(time.valueOf())) throw new Error(`Meetup event ${url} has no valid ${field}.`);
return time.toISOString();
}

function eventId(event, url) {
Expand Down Expand Up @@ -104,12 +107,16 @@ function eventSchema(html, url) {
throw new Error(`Meetup event page has no Event structured data: ${url}`);
}

function eventMetadata(event) {
export function eventMetadata(event, url) {
const virtual = /(?:Online|Mixed)EventAttendanceMode$/.test(event.eventAttendanceMode ?? '');
const locations = Array.isArray(event.location) ? event.location : [event.location].filter(Boolean);
const place = locations.find(({ '@type': type }) => type === 'Place');
const times = {
startDate: eventTime(event.startDate, 'start date', url),
endDate: event.endDate ? eventTime(event.endDate, 'end date', url) : undefined,
};

if (!place) return { virtual, where: virtual ? 'Online' : '' };
if (!place) return { ...times, virtual, where: virtual ? 'Online' : '' };

const address = place.address;
const addressParts = typeof address === 'string'
Expand All @@ -119,19 +126,17 @@ function eventMetadata(event) {
(parts, part) => parts.some((existing) => existing.toLowerCase().includes(part.toLowerCase())) ? parts : [...parts, part],
[],
).join(', ');
return { virtual, where };
return { ...times, virtual, where };
}

function eventFile(event, metadata, groupName) {
const url = event.URL;
if (!url) throw new Error('Meetup calendar event has no URL.');

const startDate = date(event.DTSTART, 'start date', url);
const endDate = event.DTEND ? date(event.DTEND, 'end date', url) : undefined;
const endDateField = endDate && endDate !== startDate ? `endDate: ${yaml(endDate)}\n` : '';
const endDateField = metadata.endDate ? `endDate: ${yaml(metadata.endDate)}\n` : '';
const description = unescapeIcal(event.DESCRIPTION).trim();

return `---\nmeetupEventId: ${yaml(eventId(event, url))}\nmeetupSource: meetup\nstartDate: ${yaml(startDate)}\n${endDateField}title: ${yaml(unescapeIcal(event.SUMMARY))}\nexternalUrl: ${yaml(url)}\nvirtual: ${metadata.virtual}\nwhere: ${yaml(metadata.where || groupName)}\n---\n${description}\n`;
return `---\nmeetupEventId: ${yaml(eventId(event, url))}\nmeetupSource: meetup\nstartDate: ${yaml(metadata.startDate)}\n${endDateField}title: ${yaml(unescapeIcal(event.SUMMARY))}\nexternalUrl: ${yaml(url)}\nvirtual: ${metadata.virtual}\nwhere: ${yaml(metadata.where || groupName)}\n---\n${description}\n`;
}

async function groups() {
Expand Down Expand Up @@ -167,7 +172,7 @@ async function fetchGroupEvents(urlname) {

const page = await fetch(event.URL);
if (!page.ok) throw new Error(`Meetup event page returned ${page.status}: ${event.URL}`);
return { event, metadata: eventMetadata(eventSchema(await page.text(), event.URL)) };
return { event, metadata: eventMetadata(eventSchema(await page.text(), event.URL), event.URL) };
}));

return { events, groupName: calendar.groupName || urlname };
Expand Down
41 changes: 40 additions & 1 deletion .github/scripts/sync-meetup-events.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { calendarSyncPlan } from './sync-meetup-events.mjs';
import { calendarSyncPlan, eventMetadata } from './sync-meetup-events.mjs';

const managedEvent = {
file: 'content/calendar/meetup-123.md',
Expand Down Expand Up @@ -34,3 +34,42 @@ test('retains manual historical events when Meetup reports cancellation', () =>

assert.deepEqual([...plan.cancelled], []);
});

test('preserves the start and end instants supplied by Meetup', () => {
const event = {
UID: 'event_456@meetup.com',
DTSTART: '20260916T000000Z',
DTEND: '20260916T010000Z',
SUMMARY: 'Intune - Not Just Another Graph Resource',
DESCRIPTION: 'Hailey presents Intune automation.',
URL: 'https://www.meetup.com/example/events/456/',
};
const metadata = eventMetadata({
eventAttendanceMode: 'https://schema.org/OnlineEventAttendanceMode',
location: { '@type': 'VirtualLocation' },
startDate: '2026-09-16T19:00:00-04:00',
endDate: '2026-09-16T20:00:00-04:00',
}, event.URL);

assert.deepEqual(metadata, {
virtual: true,
where: 'Online',
startDate: '2026-09-16T23:00:00.000Z',
endDate: '2026-09-17T00:00:00.000Z',
});

const plan = calendarSyncPlan([], [{ events: [{ event, metadata }], groupName: 'Example group' }]);

assert.equal(plan.desired.get('content/calendar/meetup-456.md'), `---
meetupEventId: "456"
meetupSource: meetup
startDate: "2026-09-16T23:00:00.000Z"
endDate: "2026-09-17T00:00:00.000Z"
title: "Intune - Not Just Another Graph Resource"
externalUrl: "https://www.meetup.com/example/events/456/"
virtual: true
where: "Online"
---
Hailey presents Intune automation.
`);
});
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ jobs:
npm install
fi

- name: Test Meetup calendar synchronization
run: node --test .github/scripts/sync-meetup-events.test.mjs

- name: Create data directory
run: mkdir -p data

Expand Down
3 changes: 2 additions & 1 deletion content/calendar/meetup-316490583.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
---
meetupEventId: "316490583"
meetupSource: meetup
startDate: "2026-09-16"
startDate: "2026-09-16T18:00:00-04:00"
endDate: "2026-09-16T20:00:00-04:00"
title: "Intune - Not Just Another Graph Resource"
externalUrl: "https://www.meetup.com/research-triangle-powershell-users-group/events/316490583/"
virtual: true
Expand Down
9 changes: 7 additions & 2 deletions layouts/_partials/event.ics
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@
{{- if or (eq hugo.Environment "development") (eq hugo.Environment "debug") }}{{ warnf "Partial used: %s" templates.Current.Name }}{{ end -}}
{{- $timezone := partial "ical/get_timezone.ics" . -}}
{{- $lang := .Site.Language.Lang -}}
{{- $startDate := (time.AsTime .Params.startDate $timezone) | time.In $timezone -}}
{{- $allDay := not (strings.Contains .Params.startDate "T") -}}
{{- $endDate := "" -}}
{{- if .Params.endDate -}}
{{- $endDate = (time.AsTime .Params.endDate $timezone) | time.In $timezone -}}
{{- end -}}
{{- if $allDay -}}
{{- $endDate = (cond (ne $endDate "") $endDate $startDate).AddDate 0 0 1 -}}
{{- end -}}
{{- with dict `` `
` "description" (dict "text" .Plain "lang" $lang) `` `
` "summary" (dict "text" .Title "lang" $lang) `` `
` "eventStart" (dict "dateTime" ((time.AsTime .Params.startDate $timezone) | time.In $timezone) "timeZoneID" $timezone) `` `
` "eventStart" (dict "dateTime" $startDate "timeZoneID" $timezone) `` `
` "eventEnd" (cond (ne $endDate "") (dict "dateTime" $endDate "timeZoneID" $timezone) nil) `` `
` "location" (dict "text" (or .Params.where .Params.location) "lang" $lang) `` `
` "url" (or .Params.externalUrl ((.OutputFormats.Get "HTML").Permalink)) `` `
` "contact" (cond (and .Params.orga .Params.orgaEmail) (dict "text" (printf "%s: %s" .Params.orga .Params.orgaEmail) "lang" $lang) nil) `` `
` "color" "7C3AED" `` `
` "status" (cond (.Params.cancelled | default false) "CANCELLED" "CONFIRMED") `` `
` "allDay" $allDay `` `
` "uid" .File.UniqueID `` `
` "timestamp" .Date `` `
` "created" .Date `` `
Expand Down