Skip to content

Commit e1393d7

Browse files
Add Meetup event synchronization (#82)
1 parent 50e1a9f commit e1393d7

6 files changed

Lines changed: 287 additions & 5 deletions

File tree

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
4+
const GROUPS_FILE = path.join('data', 'meetup_groups.json');
5+
const CALENDAR_DIR = path.join('content', 'calendar');
6+
const DRY_RUN = process.argv.includes('--dry-run');
7+
8+
function yaml(value) {
9+
return JSON.stringify(value ?? '');
10+
}
11+
12+
function unescapeIcal(value = '') {
13+
return value
14+
.replace(/\\n/gi, '\n')
15+
.replace(/\\,/g, ',')
16+
.replace(/\\;/g, ';')
17+
.replace(/\\\\/g, '\\');
18+
}
19+
20+
function property(line) {
21+
const separator = line.indexOf(':');
22+
if (separator < 1) return null;
23+
const declaration = line.slice(0, separator);
24+
return { name: declaration.split(';', 1)[0], value: line.slice(separator + 1) };
25+
}
26+
27+
function eventsFromIcal(calendar) {
28+
const events = [];
29+
let event;
30+
let groupName;
31+
32+
for (const line of calendar.replace(/\r?\n[ \t]/g, '').split(/\r?\n/)) {
33+
if (line === 'BEGIN:VEVENT') {
34+
event = {};
35+
continue;
36+
}
37+
if (line === 'END:VEVENT') {
38+
if (event) events.push(event);
39+
event = undefined;
40+
continue;
41+
}
42+
43+
const parsed = property(line);
44+
if (!parsed) continue;
45+
if (event) event[parsed.name] = parsed.value;
46+
if (parsed.name === 'X-WR-CALNAME') groupName = unescapeIcal(parsed.value);
47+
}
48+
49+
return { events, groupName };
50+
}
51+
52+
function date(value, field, url) {
53+
const match = value?.match(/^(\d{4})(\d{2})(\d{2})/);
54+
if (!match) throw new Error(`Meetup event ${url} has no valid ${field}.`);
55+
return `${match[1]}-${match[2]}-${match[3]}`;
56+
}
57+
58+
function eventId(event, url) {
59+
const match = event.UID?.match(/^event_(.+?)@meetup\.com$/);
60+
if (match) return match[1];
61+
const urlMatch = url.match(/\/events\/([^/?#]+)/);
62+
if (urlMatch) return urlMatch[1];
63+
throw new Error(`Meetup calendar event has no recognized ID: ${url}`);
64+
}
65+
66+
function eventSchema(html, url) {
67+
const scripts = [...html.matchAll(/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)];
68+
for (const [, script] of scripts) {
69+
const value = JSON.parse(script);
70+
const candidates = Array.isArray(value) ? value : value['@graph'] ?? [value];
71+
const event = candidates.find(({ '@type': type }) => type === 'Event');
72+
if (event) return event;
73+
}
74+
throw new Error(`Meetup event page has no Event structured data: ${url}`);
75+
}
76+
77+
function eventMetadata(event) {
78+
const virtual = /(?:Online|Mixed)EventAttendanceMode$/.test(event.eventAttendanceMode ?? '');
79+
const locations = Array.isArray(event.location) ? event.location : [event.location].filter(Boolean);
80+
const place = locations.find(({ '@type': type }) => type === 'Place');
81+
82+
if (!place) return { virtual, where: virtual ? 'Online' : '' };
83+
84+
const address = place.address;
85+
const addressParts = typeof address === 'string'
86+
? [address]
87+
: [address?.streetAddress, address?.addressLocality, address?.addressRegion, address?.addressCountry];
88+
const where = [place.name, ...addressParts].filter(Boolean).reduce(
89+
(parts, part) => parts.some((existing) => existing.toLowerCase().includes(part.toLowerCase())) ? parts : [...parts, part],
90+
[],
91+
).join(', ');
92+
return { virtual, where };
93+
}
94+
95+
function eventFile(event, metadata, groupName) {
96+
const url = event.URL;
97+
if (!url) throw new Error('Meetup calendar event has no URL.');
98+
99+
const startDate = date(event.DTSTART, 'start date', url);
100+
const endDate = event.DTEND ? date(event.DTEND, 'end date', url) : undefined;
101+
const endDateField = endDate && endDate !== startDate ? `endDate: ${yaml(endDate)}\n` : '';
102+
const description = unescapeIcal(event.DESCRIPTION).trim();
103+
104+
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`;
105+
}
106+
107+
async function groups() {
108+
const parsed = JSON.parse(await readFile(GROUPS_FILE, 'utf8'));
109+
if (!Array.isArray(parsed) || !parsed.every((group) => group && typeof group === 'object' && typeof group.urlname === 'string' && group.urlname)) {
110+
throw new Error(`${GROUPS_FILE} must be an array of Meetup group objects with a urlname.`);
111+
}
112+
return parsed;
113+
}
114+
115+
async function fetchGroupEvents(urlname) {
116+
const response = await fetch(`https://www.meetup.com/${urlname}/events/ical/`);
117+
if (!response.ok) throw new Error(`Meetup iCalendar feed returned ${response.status} for ${urlname}.`);
118+
119+
const calendar = eventsFromIcal(await response.text());
120+
const events = await Promise.all(calendar.events.map(async (event) => {
121+
if (event.STATUS === 'CANCELLED') return { event };
122+
if (!event.URL || !event.DTSTART || !event.SUMMARY) {
123+
throw new Error(`Meetup calendar event for ${urlname} is missing required fields.`);
124+
}
125+
126+
const page = await fetch(event.URL);
127+
if (!page.ok) throw new Error(`Meetup event page returned ${page.status}: ${event.URL}`);
128+
return { event, metadata: eventMetadata(eventSchema(await page.text(), event.URL)) };
129+
}));
130+
131+
return { events, groupName: calendar.groupName || urlname };
132+
}
133+
134+
async function calendarFiles() {
135+
const names = await readdir(CALENDAR_DIR);
136+
return Promise.all(names.filter((name) => name.endsWith('.md')).map(async (name) => ({
137+
file: path.join(CALENDAR_DIR, name),
138+
content: await readFile(path.join(CALENDAR_DIR, name), 'utf8'),
139+
})));
140+
}
141+
142+
async function main() {
143+
const configuredGroups = await groups();
144+
const groupEvents = await Promise.all(configuredGroups.map(({ urlname }) => fetchGroupEvents(urlname)));
145+
await mkdir(CALENDAR_DIR, { recursive: true });
146+
147+
const calendar = await calendarFiles();
148+
const managed = new Set(calendar.filter(({ content }) => content.includes('meetupSource: meetup')).map(({ file }) => file));
149+
const manualUrls = new Set(calendar
150+
.filter(({ content }) => !content.includes('meetupSource: meetup'))
151+
.map(({ content }) => content.match(/^externalUrl:\s*["']?([^\s"']+)/m)?.[1])
152+
.filter(Boolean));
153+
const desired = new Map();
154+
155+
for (const { events, groupName } of groupEvents) {
156+
for (const { event, metadata } of events) {
157+
if (event.STATUS === 'CANCELLED' || manualUrls.has(event.URL)) continue;
158+
desired.set(path.join(CALENDAR_DIR, `meetup-${eventId(event, event.URL)}.md`), eventFile(event, metadata, groupName));
159+
}
160+
}
161+
162+
const changes = [];
163+
for (const [file, content] of desired) {
164+
const current = calendar.find((entry) => entry.file === file)?.content;
165+
if (current !== content) {
166+
changes.push(`${current === undefined ? 'add' : 'update'} ${file}`);
167+
if (!DRY_RUN) await writeFile(file, content);
168+
}
169+
managed.delete(file);
170+
}
171+
172+
for (const file of managed) {
173+
changes.push(`remove ${file}`);
174+
if (!DRY_RUN) await rm(file);
175+
}
176+
177+
console.log(changes.length ? changes.join('\n') : 'Meetup events are already synchronized.');
178+
}
179+
180+
main().catch((error) => {
181+
console.error(error.message);
182+
process.exitCode = 1;
183+
});

.github/workflows/meetup-sync.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Sync Meetup Events
2+
3+
on:
4+
schedule:
5+
- cron: '15 */6 * * *'
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
pull-requests: write
11+
12+
concurrency:
13+
group: sync-meetup-events
14+
cancel-in-progress: false
15+
16+
jobs:
17+
sync-meetup-events:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Checkout
21+
uses: actions/checkout@v4
22+
23+
- name: Setup Node.js
24+
uses: actions/setup-node@v4
25+
with:
26+
node-version: '20'
27+
28+
- name: Sync upcoming Meetup events
29+
run: node .github/scripts/sync-meetup-events.mjs
30+
31+
- name: Create pull request for synchronized events
32+
uses: peter-evans/create-pull-request@v8.1.1
33+
with:
34+
add-paths: |
35+
content/calendar
36+
data/meetup_groups.json
37+
branch: automation/sync-meetup-events
38+
commit-message: Sync Meetup events
39+
title: Sync Meetup events
40+
body: |
41+
Automated synchronization of upcoming events from configured Meetup groups.
42+
delete-branch: true

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ npm run dev
3232
This serves the site at `http://localhost:1313` with hot-reload and draft posts
3333
visible. Save your Markdown file and the browser updates automatically.
3434

35+
### Syncing Meetup user-group events
36+
37+
The Community Calendar automatically syncs upcoming events from configured
38+
Meetup groups. Add the group's Meetup URL name to
39+
[`data/meetup_groups.json`](data/meetup_groups.json), then verify its public
40+
`https://www.meetup.com/<urlname>/events/ical/` feed contains the intended
41+
events. The scheduled **Sync Meetup Events** workflow reads that public feed
42+
and its linked event pages; no Meetup API key or OAuth token is required.
43+
44+
To preview the generated calendar changes locally without writing files:
45+
46+
```bash
47+
node .github/scripts/sync-meetup-events.mjs --dry-run
48+
```
49+
3550
## What the site includes
3651

3752
- **Home** — community stats and the latest content.

content/calendar/swiss-psug-09-2026-2026.md renamed to content/calendar/meetup-315958850.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
---
2-
endDate: '2026-09-09'
3-
externalUrl: https://www.meetup.com/swiss-powershell-user-group/events/315958850/
4-
startDate: '2026-09-09'
5-
title: Swiss PSUG 09/2026
2+
meetupEventId: "315958850"
3+
meetupSource: meetup
4+
startDate: "2026-09-09"
5+
title: "Swiss PSUG 09/2026"
6+
externalUrl: "https://www.meetup.com/swiss-powershell-user-group/events/315958850/"
67
virtual: true
7-
where: Bern, Switzerland
8+
where: "isolutions AG, Schanzenstrasse 4c, Bern"
89
---
10+
Swiss PowerShell User Group
11+
Dear Swiss PSUG,
12+
We have planned another exciting event.
13+
914
**Place & Language:**
1015
Place: Isolutions AG, Schanzenstrasse 4c, 3008 Bern (Hybrid with Teams)
1116
Language: EN
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
meetupEventId: "316283278"
3+
meetupSource: meetup
4+
startDate: "2026-09-02"
5+
title: "NeoVIM for PowerShell!"
6+
externalUrl: "https://www.meetup.com/research-triangle-powershell-users-group/events/316283278/"
7+
virtual: true
8+
where: "Online"
9+
---
10+
Research Triangle PowerShell Users Group
11+
PowerShell development doesn't require VSCode. In this session, Rob shares his approach to building an efficient, terminal-first workflow using NeoVIM—and demonstrates how this setup powers real work in DevOps, Cloud Security, and Application Security environments.
12+
13+
This session explores alternative approaches to PowerShell development—specifically, building an efficient workflow without relying on traditional GUI-based IDEs.
14+
Drawing from 13+ years across System Administration, DevOps, Cloud Security, and Application Security, Rob Pleau shares the tools, configurations, and strategies that enable productive PowerShell development in a terminal environment.
15+
16+
**Topics include:**
17+
18+
* Why terminal-first workflows can be more efficient for certain tasks
19+
* Editor options and setup for cross-platform consistency
20+
* Practical tooling and configurations
21+
* Real-world examples from professional PowerShell work
22+
* Tips applicable to any development environment
23+
*

data/meetup_groups.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[
2+
{
3+
"urlname": "research-triangle-powershell-users-group"
4+
},
5+
{
6+
"urlname": "swiss-powershell-user-group"
7+
},
8+
{
9+
"urlname": "powershell-usergroup-inn-salzach"
10+
},
11+
{
12+
"urlname": "pacific-powershell-user-group"
13+
}
14+
]

0 commit comments

Comments
 (0)