diff --git a/.github/scripts/sync-meetup-events.mjs b/.github/scripts/sync-meetup-events.mjs new file mode 100644 index 000000000..bc03f8bb7 --- /dev/null +++ b/.github/scripts/sync-meetup-events.mjs @@ -0,0 +1,183 @@ +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const GROUPS_FILE = path.join('data', 'meetup_groups.json'); +const CALENDAR_DIR = path.join('content', 'calendar'); +const DRY_RUN = process.argv.includes('--dry-run'); + +function yaml(value) { + return JSON.stringify(value ?? ''); +} + +function unescapeIcal(value = '') { + return value + .replace(/\\n/gi, '\n') + .replace(/\\,/g, ',') + .replace(/\\;/g, ';') + .replace(/\\\\/g, '\\'); +} + +function property(line) { + const separator = line.indexOf(':'); + if (separator < 1) return null; + const declaration = line.slice(0, separator); + return { name: declaration.split(';', 1)[0], value: line.slice(separator + 1) }; +} + +function eventsFromIcal(calendar) { + const events = []; + let event; + let groupName; + + for (const line of calendar.replace(/\r?\n[ \t]/g, '').split(/\r?\n/)) { + if (line === 'BEGIN:VEVENT') { + event = {}; + continue; + } + if (line === 'END:VEVENT') { + if (event) events.push(event); + event = undefined; + continue; + } + + const parsed = property(line); + if (!parsed) continue; + if (event) event[parsed.name] = parsed.value; + if (parsed.name === 'X-WR-CALNAME') groupName = unescapeIcal(parsed.value); + } + + 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 eventId(event, url) { + const match = event.UID?.match(/^event_(.+?)@meetup\.com$/); + if (match) return match[1]; + const urlMatch = url.match(/\/events\/([^/?#]+)/); + if (urlMatch) return urlMatch[1]; + throw new Error(`Meetup calendar event has no recognized ID: ${url}`); +} + +function eventSchema(html, url) { + const scripts = [...html.matchAll(/]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)]; + for (const [, script] of scripts) { + const value = JSON.parse(script); + const candidates = Array.isArray(value) ? value : value['@graph'] ?? [value]; + const event = candidates.find(({ '@type': type }) => type === 'Event'); + if (event) return event; + } + throw new Error(`Meetup event page has no Event structured data: ${url}`); +} + +function eventMetadata(event) { + 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'); + + if (!place) return { virtual, where: virtual ? 'Online' : '' }; + + const address = place.address; + const addressParts = typeof address === 'string' + ? [address] + : [address?.streetAddress, address?.addressLocality, address?.addressRegion, address?.addressCountry]; + const where = [place.name, ...addressParts].filter(Boolean).reduce( + (parts, part) => parts.some((existing) => existing.toLowerCase().includes(part.toLowerCase())) ? parts : [...parts, part], + [], + ).join(', '); + return { 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 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`; +} + +async function groups() { + const parsed = JSON.parse(await readFile(GROUPS_FILE, 'utf8')); + if (!Array.isArray(parsed) || !parsed.every((group) => group && typeof group === 'object' && typeof group.urlname === 'string' && group.urlname)) { + throw new Error(`${GROUPS_FILE} must be an array of Meetup group objects with a urlname.`); + } + return parsed; +} + +async function fetchGroupEvents(urlname) { + const response = await fetch(`https://www.meetup.com/${urlname}/events/ical/`); + if (!response.ok) throw new Error(`Meetup iCalendar feed returned ${response.status} for ${urlname}.`); + + const calendar = eventsFromIcal(await response.text()); + const events = await Promise.all(calendar.events.map(async (event) => { + if (event.STATUS === 'CANCELLED') return { event }; + if (!event.URL || !event.DTSTART || !event.SUMMARY) { + throw new Error(`Meetup calendar event for ${urlname} is missing required fields.`); + } + + 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 { events, groupName: calendar.groupName || urlname }; +} + +async function calendarFiles() { + const names = await readdir(CALENDAR_DIR); + return Promise.all(names.filter((name) => name.endsWith('.md')).map(async (name) => ({ + file: path.join(CALENDAR_DIR, name), + content: await readFile(path.join(CALENDAR_DIR, name), 'utf8'), + }))); +} + +async function main() { + const configuredGroups = await groups(); + const groupEvents = await Promise.all(configuredGroups.map(({ urlname }) => fetchGroupEvents(urlname))); + await mkdir(CALENDAR_DIR, { recursive: true }); + + const calendar = await calendarFiles(); + const managed = new Set(calendar.filter(({ content }) => content.includes('meetupSource: meetup')).map(({ file }) => file)); + const manualUrls = new Set(calendar + .filter(({ content }) => !content.includes('meetupSource: meetup')) + .map(({ content }) => content.match(/^externalUrl:\s*["']?([^\s"']+)/m)?.[1]) + .filter(Boolean)); + const desired = new Map(); + + for (const { events, groupName } of groupEvents) { + for (const { event, metadata } of events) { + if (event.STATUS === 'CANCELLED' || manualUrls.has(event.URL)) continue; + desired.set(path.join(CALENDAR_DIR, `meetup-${eventId(event, event.URL)}.md`), eventFile(event, metadata, groupName)); + } + } + + const changes = []; + for (const [file, content] of desired) { + const current = calendar.find((entry) => entry.file === file)?.content; + if (current !== content) { + changes.push(`${current === undefined ? 'add' : 'update'} ${file}`); + if (!DRY_RUN) await writeFile(file, content); + } + managed.delete(file); + } + + for (const file of managed) { + changes.push(`remove ${file}`); + if (!DRY_RUN) await rm(file); + } + + console.log(changes.length ? changes.join('\n') : 'Meetup events are already synchronized.'); +} + +main().catch((error) => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/.github/workflows/meetup-sync.yml b/.github/workflows/meetup-sync.yml new file mode 100644 index 000000000..4ca1e480b --- /dev/null +++ b/.github/workflows/meetup-sync.yml @@ -0,0 +1,42 @@ +name: Sync Meetup Events + +on: + schedule: + - cron: '15 */6 * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: sync-meetup-events + cancel-in-progress: false + +jobs: + sync-meetup-events: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Sync upcoming Meetup events + run: node .github/scripts/sync-meetup-events.mjs + + - name: Create pull request for synchronized events + uses: peter-evans/create-pull-request@v8.1.1 + with: + add-paths: | + content/calendar + data/meetup_groups.json + branch: automation/sync-meetup-events + commit-message: Sync Meetup events + title: Sync Meetup events + body: | + Automated synchronization of upcoming events from configured Meetup groups. + delete-branch: true diff --git a/README.md b/README.md index c0dbea2e5..d3e263cf8 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,21 @@ npm run dev This serves the site at `http://localhost:1313` with hot-reload and draft posts visible. Save your Markdown file and the browser updates automatically. +### Syncing Meetup user-group events + +The Community Calendar automatically syncs upcoming events from configured +Meetup groups. Add the group's Meetup URL name to +[`data/meetup_groups.json`](data/meetup_groups.json), then verify its public +`https://www.meetup.com//events/ical/` feed contains the intended +events. The scheduled **Sync Meetup Events** workflow reads that public feed +and its linked event pages; no Meetup API key or OAuth token is required. + +To preview the generated calendar changes locally without writing files: + +```bash +node .github/scripts/sync-meetup-events.mjs --dry-run +``` + ## What the site includes - **Home** — community stats and the latest content. diff --git a/content/calendar/swiss-psug-09-2026-2026.md b/content/calendar/meetup-315958850.md similarity index 83% rename from content/calendar/swiss-psug-09-2026-2026.md rename to content/calendar/meetup-315958850.md index 80fe96cf1..998677007 100644 --- a/content/calendar/swiss-psug-09-2026-2026.md +++ b/content/calendar/meetup-315958850.md @@ -1,11 +1,16 @@ --- -endDate: '2026-09-09' -externalUrl: https://www.meetup.com/swiss-powershell-user-group/events/315958850/ -startDate: '2026-09-09' -title: Swiss PSUG 09/2026 +meetupEventId: "315958850" +meetupSource: meetup +startDate: "2026-09-09" +title: "Swiss PSUG 09/2026" +externalUrl: "https://www.meetup.com/swiss-powershell-user-group/events/315958850/" virtual: true -where: Bern, Switzerland +where: "isolutions AG, Schanzenstrasse 4c, Bern" --- +Swiss PowerShell User Group +Dear Swiss PSUG, +We have planned another exciting event. + **Place & Language:** Place: Isolutions AG, Schanzenstrasse 4c, 3008 Bern (Hybrid with Teams) Language: EN diff --git a/content/calendar/meetup-316283278.md b/content/calendar/meetup-316283278.md new file mode 100644 index 000000000..f4d2665cd --- /dev/null +++ b/content/calendar/meetup-316283278.md @@ -0,0 +1,23 @@ +--- +meetupEventId: "316283278" +meetupSource: meetup +startDate: "2026-09-02" +title: "NeoVIM for PowerShell!" +externalUrl: "https://www.meetup.com/research-triangle-powershell-users-group/events/316283278/" +virtual: true +where: "Online" +--- +Research Triangle PowerShell Users Group +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. + +This session explores alternative approaches to PowerShell development—specifically, building an efficient workflow without relying on traditional GUI-based IDEs. +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. + +**Topics include:** + +* Why terminal-first workflows can be more efficient for certain tasks +* Editor options and setup for cross-platform consistency +* Practical tooling and configurations +* Real-world examples from professional PowerShell work +* Tips applicable to any development environment +* diff --git a/data/meetup_groups.json b/data/meetup_groups.json new file mode 100644 index 000000000..cf66b2800 --- /dev/null +++ b/data/meetup_groups.json @@ -0,0 +1,14 @@ +[ + { + "urlname": "research-triangle-powershell-users-group" + }, + { + "urlname": "swiss-powershell-user-group" + }, + { + "urlname": "powershell-usergroup-inn-salzach" + }, + { + "urlname": "pacific-powershell-user-group" + } +]