From f5c9dc4e80ff2bed18916bf4fadb34921875a9c6 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Wed, 26 Aug 2026 21:35:11 +0000 Subject: [PATCH 1/5] Add Meetup event synchronization --- .github/scripts/sync-meetup-events.mjs | 166 +++++++++++++++++++++++++ .github/workflows/meetup-sync.yml | 44 +++++++ data/meetup_groups.json | 5 + 3 files changed, 215 insertions(+) create mode 100644 .github/scripts/sync-meetup-events.mjs create mode 100644 .github/workflows/meetup-sync.yml create mode 100644 data/meetup_groups.json diff --git a/.github/scripts/sync-meetup-events.mjs b/.github/scripts/sync-meetup-events.mjs new file mode 100644 index 000000000..172f1fe97 --- /dev/null +++ b/.github/scripts/sync-meetup-events.mjs @@ -0,0 +1,166 @@ +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const API_URL = 'https://api.meetup.com/gql-ext'; +const GROUPS_FILE = path.join('data', 'meetup_groups.json'); +const CALENDAR_DIR = path.join('content', 'calendar'); +const DRY_RUN = process.argv.includes('--dry-run'); + +const QUERY = ` + query UpcomingGroupEvents($urlname: ID!) { + group(urlname: $urlname) { + name + events(input: { first: 100, filter: { status: "UPCOMING" } }) { + edges { + node { + id + title + description + dateTime + eventUrl + type + venue { + name + address + city + state + country + } + } + } + } + } + } +`; + +function yaml(value) { + return JSON.stringify(value ?? ''); +} + +function plainText(html = '') { + return html + .replace(/<\/(?:p|div|li|h[1-6])>/gi, '\n') + .replace(//gi, '\n') + .replace(/<[^>]*>/g, '') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function venueLabel(venue, groupName, isVirtual) { + if (isVirtual) return 'Online'; + + const parts = [venue?.name, venue?.address, venue?.city, venue?.state, venue?.country] + .filter(Boolean); + return parts.length ? [...new Set(parts)].join(', ') : groupName; +} + +function isVirtual(event) { + return ['ONLINE', 'HYBRID'].includes(event.type); +} + +function eventFile(event, groupName) { + const virtual = isVirtual(event); + const startDate = event.dateTime.slice(0, 10); + const body = plainText(event.description); + + return `---\nmeetupEventId: ${yaml(String(event.id))}\nmeetupSource: meetup\nstartDate: ${yaml(startDate)}\ntitle: ${yaml(event.title)}\nexternalUrl: ${yaml(event.eventUrl)}\nvirtual: ${virtual}\nwhere: ${yaml(venueLabel(event.venue, groupName, virtual))}\n---\n${body}\n`; +} + +async function groups() { + const parsed = JSON.parse(await readFile(GROUPS_FILE, 'utf8')); + if (!Array.isArray(parsed) || !parsed.every(({ urlname }) => typeof urlname === 'string' && urlname)) { + throw new Error(`${GROUPS_FILE} must be an array of Meetup group objects with a urlname.`); + } + return parsed; +} + +async function fetchEvents(urlname, token) { + const response = await fetch(API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query: QUERY, variables: { urlname } }), + }); + + if (!response.ok) { + throw new Error(`Meetup returned ${response.status} for ${urlname}.`); + } + + const result = await response.json(); + if (result.errors?.length) { + throw new Error(`Meetup query failed for ${urlname}: ${result.errors.map(({ message }) => message).join('; ')}`); + } + if (!result.data?.group) { + throw new Error(`Meetup group ${urlname} was not found or is not accessible to this token.`); + } + + return result.data.group; +} + +async function managedFiles() { + const names = await readdir(CALENDAR_DIR); + const files = await Promise.all(names.filter((name) => name.endsWith('.md')).map(async (name) => { + const file = path.join(CALENDAR_DIR, name); + const content = await readFile(file, 'utf8'); + return content.includes('meetupSource: meetup') ? file : null; + })); + return files.filter(Boolean); +} + +async function main() { + const token = process.env.MEETUP_ACCESS_TOKEN; + if (!token) { + throw new Error('MEETUP_ACCESS_TOKEN is required. Create it from a Meetup OAuth client and store it as a repository secret.'); + } + + const configuredGroups = await groups(); + const result = await Promise.all(configuredGroups.map(({ urlname }) => fetchEvents(urlname, token))); + const desired = new Map(); + + for (const group of result) { + for (const { node: event } of group.events.edges) { + if (!event.id || !event.dateTime || !event.eventUrl || !event.title) { + throw new Error(`Meetup event from ${group.name} is missing required calendar fields.`); + } + desired.set(path.join(CALENDAR_DIR, `meetup-${event.id}.md`), eventFile(event, group.name)); + } + } + + await mkdir(CALENDAR_DIR, { recursive: true }); + const existing = new Set(await managedFiles()); + const changes = []; + + for (const [file, content] of desired) { + let current; + try { + current = await readFile(file, 'utf8'); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + if (current !== content) { + changes.push(`${current === undefined ? 'add' : 'update'} ${file}`); + if (!DRY_RUN) await writeFile(file, content); + } + existing.delete(file); + } + + for (const file of existing) { + 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..4504a2269 --- /dev/null +++ b/.github/workflows/meetup-sync.yml @@ -0,0 +1,44 @@ +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 + env: + MEETUP_ACCESS_TOKEN: ${{ secrets.MEETUP_ACCESS_TOKEN }} + 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/data/meetup_groups.json b/data/meetup_groups.json new file mode 100644 index 000000000..1b36cde9d --- /dev/null +++ b/data/meetup_groups.json @@ -0,0 +1,5 @@ +[ + { + "urlname": "research-triangle-powershell-users-group" + } +] From 687c7e1e8f8cc2829f475ba8772564e0975b9048 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Wed, 26 Aug 2026 21:42:23 +0000 Subject: [PATCH 2/5] Add Meetup group sources --- data/meetup_groups.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/data/meetup_groups.json b/data/meetup_groups.json index 1b36cde9d..cf66b2800 100644 --- a/data/meetup_groups.json +++ b/data/meetup_groups.json @@ -1,5 +1,14 @@ [ { "urlname": "research-triangle-powershell-users-group" + }, + { + "urlname": "swiss-powershell-user-group" + }, + { + "urlname": "powershell-usergroup-inn-salzach" + }, + { + "urlname": "pacific-powershell-user-group" } ] From 0c728b13dbf8f6dd0cd17cdcf94f21893f8dcae9 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Wed, 26 Aug 2026 22:15:54 +0000 Subject: [PATCH 3/5] Use public Meetup iCalendar feeds --- .github/scripts/sync-meetup-events.mjs | 219 +++++++++++++------------ .github/workflows/meetup-sync.yml | 2 - content/calendar/meetup-316283278.md | 23 +++ 3 files changed, 139 insertions(+), 105 deletions(-) create mode 100644 content/calendar/meetup-316283278.md diff --git a/.github/scripts/sync-meetup-events.mjs b/.github/scripts/sync-meetup-events.mjs index 172f1fe97..ce35dc279 100644 --- a/.github/scripts/sync-meetup-events.mjs +++ b/.github/scripts/sync-meetup-events.mjs @@ -1,75 +1,103 @@ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; -const API_URL = 'https://api.meetup.com/gql-ext'; const GROUPS_FILE = path.join('data', 'meetup_groups.json'); const CALENDAR_DIR = path.join('content', 'calendar'); const DRY_RUN = process.argv.includes('--dry-run'); -const QUERY = ` - query UpcomingGroupEvents($urlname: ID!) { - group(urlname: $urlname) { - name - events(input: { first: 100, filter: { status: "UPCOMING" } }) { - edges { - node { - id - title - description - dateTime - eventUrl - type - venue { - name - address - city - state - country - } - } - } - } +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); } -`; -function yaml(value) { - return JSON.stringify(value ?? ''); + return { events, groupName }; } -function plainText(html = '') { - return html - .replace(/<\/(?:p|div|li|h[1-6])>/gi, '\n') - .replace(//gi, '\n') - .replace(/<[^>]*>/g, '') - .replace(/ /gi, ' ') - .replace(/&/gi, '&') - .replace(/</gi, '<') - .replace(/>/gi, '>') - .replace(/"/gi, '"') - .replace(/'/gi, "'") - .replace(/\n{3,}/g, '\n\n') - .trim(); +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 venueLabel(venue, groupName, isVirtual) { - if (isVirtual) return 'Online'; +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}`); +} - const parts = [venue?.name, venue?.address, venue?.city, venue?.state, venue?.country] - .filter(Boolean); - return parts.length ? [...new Set(parts)].join(', ') : groupName; +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 isVirtual(event) { - return ['ONLINE', 'HYBRID'].includes(event.type); +function eventMetadata(event) { + const virtual = /(?:Online|Mixed)EventAttendanceMode$/.test(event.eventAttendanceMode ?? ''); + if (virtual && event.location?.['@type'] === 'VirtualLocation') { + return { virtual, where: 'Online' }; + } + + const address = event.location?.address; + const addressParts = typeof address === 'string' + ? [address] + : [address?.streetAddress, address?.addressLocality, address?.addressRegion, address?.addressCountry]; + const where = [event.location?.name, ...addressParts].filter(Boolean).join(', '); + return { virtual, where }; } -function eventFile(event, groupName) { - const virtual = isVirtual(event); - const startDate = event.dateTime.slice(0, 10); - const body = plainText(event.description); +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(String(event.id))}\nmeetupSource: meetup\nstartDate: ${yaml(startDate)}\ntitle: ${yaml(event.title)}\nexternalUrl: ${yaml(event.eventUrl)}\nvirtual: ${virtual}\nwhere: ${yaml(venueLabel(event.venue, groupName, virtual))}\n---\n${body}\n`; + 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() { @@ -80,79 +108,64 @@ async function groups() { return parsed; } -async function fetchEvents(urlname, token) { - const response = await fetch(API_URL, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query: QUERY, variables: { urlname } }), - }); - - if (!response.ok) { - throw new Error(`Meetup returned ${response.status} for ${urlname}.`); - } +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 result = await response.json(); - if (result.errors?.length) { - throw new Error(`Meetup query failed for ${urlname}: ${result.errors.map(({ message }) => message).join('; ')}`); - } - if (!result.data?.group) { - throw new Error(`Meetup group ${urlname} was not found or is not accessible to this token.`); - } + 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 result.data.group; + return { events, groupName: calendar.groupName || urlname }; } -async function managedFiles() { +async function calendarFiles() { const names = await readdir(CALENDAR_DIR); - const files = await Promise.all(names.filter((name) => name.endsWith('.md')).map(async (name) => { - const file = path.join(CALENDAR_DIR, name); - const content = await readFile(file, 'utf8'); - return content.includes('meetupSource: meetup') ? file : null; - })); - return files.filter(Boolean); + 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 token = process.env.MEETUP_ACCESS_TOKEN; - if (!token) { - throw new Error('MEETUP_ACCESS_TOKEN is required. Create it from a Meetup OAuth client and store it as a repository secret.'); - } - const configuredGroups = await groups(); - const result = await Promise.all(configuredGroups.map(({ urlname }) => fetchEvents(urlname, token))); + 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 group of result) { - for (const { node: event } of group.events.edges) { - if (!event.id || !event.dateTime || !event.eventUrl || !event.title) { - throw new Error(`Meetup event from ${group.name} is missing required calendar fields.`); - } - desired.set(path.join(CALENDAR_DIR, `meetup-${event.id}.md`), eventFile(event, group.name)); + 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)); } } - await mkdir(CALENDAR_DIR, { recursive: true }); - const existing = new Set(await managedFiles()); const changes = []; - for (const [file, content] of desired) { - let current; - try { - current = await readFile(file, 'utf8'); - } catch (error) { - if (error.code !== 'ENOENT') throw error; - } + 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); } - existing.delete(file); + managed.delete(file); } - for (const file of existing) { + for (const file of managed) { changes.push(`remove ${file}`); if (!DRY_RUN) await rm(file); } diff --git a/.github/workflows/meetup-sync.yml b/.github/workflows/meetup-sync.yml index 4504a2269..4ca1e480b 100644 --- a/.github/workflows/meetup-sync.yml +++ b/.github/workflows/meetup-sync.yml @@ -26,8 +26,6 @@ jobs: node-version: '20' - name: Sync upcoming Meetup events - env: - MEETUP_ACCESS_TOKEN: ${{ secrets.MEETUP_ACCESS_TOKEN }} run: node .github/scripts/sync-meetup-events.mjs - name: Create pull request for synchronized events 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 +* From 97a12548d23c1051d71b963e97d766d7e56fa20d Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Wed, 26 Aug 2026 15:17:42 -0700 Subject: [PATCH 4/5] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/scripts/sync-meetup-events.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/sync-meetup-events.mjs b/.github/scripts/sync-meetup-events.mjs index ce35dc279..ed21659e1 100644 --- a/.github/scripts/sync-meetup-events.mjs +++ b/.github/scripts/sync-meetup-events.mjs @@ -102,7 +102,7 @@ function eventFile(event, metadata, groupName) { async function groups() { const parsed = JSON.parse(await readFile(GROUPS_FILE, 'utf8')); - if (!Array.isArray(parsed) || !parsed.every(({ urlname }) => typeof urlname === 'string' && urlname)) { + 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; From 3fb28413b2aeb86ebc4597c788db37e2065c50ca Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Wed, 26 Aug 2026 22:24:31 +0000 Subject: [PATCH 5/5] Sync Swiss PSUG Meetup event --- .github/scripts/sync-meetup-events.mjs | 14 +++++++++----- README.md | 15 +++++++++++++++ ...s-psug-09-2026-2026.md => meetup-315958850.md} | 15 ++++++++++----- 3 files changed, 34 insertions(+), 10 deletions(-) rename content/calendar/{swiss-psug-09-2026-2026.md => meetup-315958850.md} (83%) diff --git a/.github/scripts/sync-meetup-events.mjs b/.github/scripts/sync-meetup-events.mjs index ed21659e1..bc03f8bb7 100644 --- a/.github/scripts/sync-meetup-events.mjs +++ b/.github/scripts/sync-meetup-events.mjs @@ -76,15 +76,19 @@ function eventSchema(html, url) { function eventMetadata(event) { const virtual = /(?:Online|Mixed)EventAttendanceMode$/.test(event.eventAttendanceMode ?? ''); - if (virtual && event.location?.['@type'] === 'VirtualLocation') { - return { virtual, where: 'Online' }; - } + 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 = event.location?.address; + const address = place.address; const addressParts = typeof address === 'string' ? [address] : [address?.streetAddress, address?.addressLocality, address?.addressRegion, address?.addressCountry]; - const where = [event.location?.name, ...addressParts].filter(Boolean).join(', '); + 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 }; } 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