diff --git a/packages/core/src/frontmatter.ts b/packages/core/src/frontmatter.ts index 14d1a61..b49afcc 100644 --- a/packages/core/src/frontmatter.ts +++ b/packages/core/src/frontmatter.ts @@ -1,3 +1,23 @@ +/** + * One event as published on an external platform, for bookkeeping in an + * adopter's source file. + * + * Platform-neutral: `group` is whatever the platform calls a community (a + * Meetup group urlname, a Luma calendar, a Discord guild), and `event_id` is a + * string so it round-trips through YAML losslessly — a large numeric id stays + * exactly as the platform reported it. + */ +export interface PublishedEventRef { + /** The platform's community identifier the event was created in. */ + group: string; + + /** The platform's event id, as a string. */ + event_id: string; + + /** Public URL of the event, when the platform reports one. */ + event_url?: string; +} + /** * Event frontmatter shape — the YAML at the top of an event markdown file. * @@ -47,6 +67,18 @@ export interface EventFrontmatter { /** Numeric Meetup event ID, or a placeholder before creation. */ event_id?: string | number; + /** + * Every group this event has been created in, including the one mirrored by + * the `event_url` / `event_id` scalars above. Written back by + * `@coopkit/meetup` when one event is cross-posted to several groups, and + * read as the per-group idempotency record: a group listed here is never + * created again. + * + * Pure bookkeeping — it describes where the event was published, not what + * the event is, so it is deliberately absent from `NormalizedEvent`. + */ + meetup_events?: PublishedEventRef[]; + /** Public registration URL (used by social media announcements). */ registration_url?: string; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 828069f..68ea41e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,3 @@ -export type { EventFrontmatter } from "./frontmatter.js"; +export type { EventFrontmatter, PublishedEventRef } from "./frontmatter.js"; export type { NormalizedEvent, NormalizedSpeaker } from "./normalized-event.js"; export { frontmatterToNormalizedEvent } from "./from-frontmatter.js"; diff --git a/packages/meetup/README.md b/packages/meetup/README.md index d88df3c..3845aaf 100644 --- a/packages/meetup/README.md +++ b/packages/meetup/README.md @@ -147,9 +147,10 @@ bunx coopkit-meetup create-from-json --groups all --dry-run event.json bunx coopkit-meetup create-from-json --groups "cpp-serbia,CPPTORONTO" event.json ``` -Without `--groups` only `groupUrlname` is used, and `--output` keeps its -original single-event shape — so existing setups are unaffected. With -`--groups`, `--output` gets a per-group breakdown instead: +Both `create` and `create-from-json` take `--groups`. Without it only +`groupUrlname` is used, and `--output` keeps its original single-event shape — +so existing setups are unaffected. With `--groups`, `--output` gets a per-group +breakdown instead: ```json { @@ -164,8 +165,75 @@ original single-event shape — so existing setups are unaffected. With Groups are processed **sequentially**, and one group failing does not stop the others — every outcome is reported so you can retry just the failures. The exit code is 1 if any group failed. There is **no rollback**: drafts already created -stay, so a retry needs the failed groups named explicitly or it will duplicate -the ones that worked. +stay. For `create-from-json` that makes a blind retry dangerous — it keeps no +record, so it would duplicate the groups that worked unless you name the failed +groups explicitly. The file-per-event path records what it created and is safe +to re-run as-is: + +#### File-per-event (`create`) + +```bash +bunx coopkit-meetup create --groups all events/2026-04-29-My-Event.md +bunx coopkit-meetup create --groups "cpp-serbia,CPPTORONTO" events/2026-04-29-My-Event.md +``` + +`create` writes every group it created into the event file, so re-running it — +or retrying after a partial failure — creates only what is missing: + +```yaml +# before +--- +title: Refactoring C++ Today +date: 2026-08-22T16:00:00Z +duration: PT1H30M +venues: + - online +--- +``` + +```yaml +# after `create --groups all`, with groupUrlname: cpp-serbia +--- +title: Refactoring C++ Today +date: 2026-08-22T16:00:00Z +duration: PT1H30M +venues: + - online +event_url: 'https://www.meetup.com/cpp-serbia/events/313413133/' +event_id: '313413133' +meetup_events: + - group: cpp-serbia + event_id: '313413133' + event_url: 'https://www.meetup.com/cpp-serbia/events/313413133/' + - group: chicago-c-cpp-users-group + event_id: '314900001' + event_url: 'https://www.meetup.com/chicago-c-cpp-users-group/events/314900001/' +--- +``` + +The `meetup_events` contract: + +- One row per group the event exists in: `group`, `event_id` (a **string**, so a + large id round-trips through YAML byte-for-byte), and `event_url` when the API + reported one. +- It is the **complete** record and includes the primary group. `event_url` / + `event_id` stay as a mirror of the primary group's row, for adopter sites and + workflows that read them. A secondary group's id is never written to those + scalars: that would make a later plain `create` skip the file and so never + create the primary event at all, and would point a site's "Register on Meetup" + link at the wrong group. +- A group listed here is never created again. It is reported as + `{"ok": true, "status": "skipped", "reason": "…"}` in `--output` — `reason` + appears only in the per-group shape. +- **No migration needed** for files written before `meetup_events` existed: the + group is read out of the legacy `event_url`, which already names it. +- The file is written after **each** group, not once at the end, so a failure + halfway through still leaves the groups that succeeded recorded on disk. +- A malformed hand-edited row (no `group`, a non-numeric `event_id`) is an + error, not a warning. Ignoring a typo would silently create a duplicate draft + — the exact failure the record exists to prevent. +- When every requested group is already recorded, nothing is written at all, so + "the file did not change" keeps meaning "no work was done". > **Why not `proNetworkEvents`?** `CreateEventInput` has a `proNetworkEvents` > input that propagates one event across a network via a saved `filterId`. That @@ -243,9 +311,14 @@ bunx coopkit-meetup create events/2026-04-29-My-Event.md # override the configured defaultHosts for one run bunx coopkit-meetup create --host "Rob Douglas" events/2026-04-29-My-Event.md + +# create the same event across a Pro network +bunx coopkit-meetup create --groups all events/2026-04-29-My-Event.md ``` -Idempotent. Writes `event_url` + `event_id` back into the file's frontmatter on success. +Idempotent. Writes `event_url` + `event_id` back into the file's frontmatter on +success, and with `--groups` a `meetup_events` list that makes the idempotency +per-group — see [Network events](#network-events-one-session-several-groups). ### From a JSON input (manual / form-driven) @@ -338,6 +411,12 @@ jobs: secrets: inherit ``` +> The workflow does **not** pass `--groups` yet. It decides whether the run did +> any work by grepping `event_url` before and after, and a `--groups` run whose +> primary group is already recorded changes only `meetup_events` — that +> write-back would be discarded. Wiring `--groups` in needs that check replaced +> with `git diff --quiet` first. + ### `_meetup-event-manual.yml` — manually-triggered with a form For repos whose events don't live in structured files. A maintainer opens the Actions tab, fills in title + date + venue + …, hits Run. No event extractor needed. diff --git a/packages/meetup/src/cli.test.ts b/packages/meetup/src/cli.test.ts index 7b079b0..09a8444 100644 --- a/packages/meetup/src/cli.test.ts +++ b/packages/meetup/src/cli.test.ts @@ -16,6 +16,22 @@ const CONFIG = JSON.stringify({ meetup: { groupUrlname: "cpp-serbia", venues: { online: 42 } }, }); +const NETWORK_CONFIG = JSON.stringify({ + meetup: { groupUrlname: "cpp-serbia", venues: { online: 42 }, groups: ["CPPTORONTO"] }, +}); + +const EVENT_MARKDOWN = [ + "---", + "title: Test Event", + "date: 2026-05-09T16:00:00.000Z", + "duration: PT1H", + "venues:", + " - online", + "---", + "", + "Body.", +].join("\n"); + const EVENT = JSON.stringify({ id: "2026-05-09-test", title: "Test Event", @@ -155,3 +171,131 @@ describe("exit codes", () => { } }); }); + +describe("create --groups (dry-run)", () => { + it("reports every group in the config and leaves the file untouched", async () => { + const dir = scratch(); + try { + const config = join(dir, "coopkit.config.json"); + const eventFile = join(dir, "2026-05-09-test.md"); + const out = join(dir, "result.json"); + writeFileSync(config, NETWORK_CONFIG); + writeFileSync(eventFile, EVENT_MARKDOWN); + + const { code } = await run([ + "create", + "--config", + config, + "--groups", + "all", + "--dry-run", + "--output", + out, + eventFile, + ]); + + expect(code).toBe(0); + const result = JSON.parse(readFileSync(out, "utf8")); + expect(result.status).toBe("dry-run"); + expect(result.groups.map((g: { groupUrlname: string }) => g.groupUrlname)).toEqual([ + "cpp-serbia", + "CPPTORONTO", + ]); + expect(readFileSync(eventFile, "utf8")).toBe(EVENT_MARKDOWN); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps the single-group --output shape without --groups", async () => { + const dir = scratch(); + try { + const config = join(dir, "coopkit.config.json"); + const eventFile = join(dir, "2026-05-09-test.md"); + const out = join(dir, "result.json"); + writeFileSync(config, NETWORK_CONFIG); + writeFileSync(eventFile, EVENT_MARKDOWN); + + const { code } = await run([ + "create", + "--config", + config, + "--dry-run", + "--output", + out, + eventFile, + ]); + + expect(code).toBe(0); + const result = JSON.parse(readFileSync(out, "utf8")); + expect(result).toEqual({ status: "dry-run" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("exits 1 and reports partial when a group fails", async () => { + const dir = scratch(); + try { + const config = join(dir, "coopkit.config.json"); + const eventFile = join(dir, "2026-05-09-test.md"); + const out = join(dir, "result.json"); + // No "online" venue: every group fails to build its payload. + writeFileSync( + config, + JSON.stringify({ + meetup: { groupUrlname: "cpp-serbia", venues: { other: 1 }, groups: ["CPPTORONTO"] }, + }) + ); + writeFileSync(eventFile, EVENT_MARKDOWN); + + const { code } = await run([ + "create", + "--config", + config, + "--groups", + "all", + "--dry-run", + "--output", + out, + eventFile, + ]); + + expect(code).toBe(1); + const result = JSON.parse(readFileSync(out, "utf8")); + expect(result.status).toBe("partial"); + expect(result.groups.every((g: { ok: boolean }) => !g.ok)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('treats --groups "," as no --groups at all, not as every group', async () => { + const dir = scratch(); + try { + const config = join(dir, "coopkit.config.json"); + const eventFile = join(dir, "2026-05-09-test.md"); + const out = join(dir, "result.json"); + writeFileSync(config, NETWORK_CONFIG); + writeFileSync(eventFile, EVENT_MARKDOWN); + + const { code } = await run([ + "create", + "--config", + config, + "--groups", + ",", + "--dry-run", + "--output", + out, + eventFile, + ]); + + expect(code).toBe(0); + // An empty parsed list used to be truthy, which silently meant "all". + expect(JSON.parse(readFileSync(out, "utf8"))).toEqual({ status: "dry-run" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/meetup/src/cli.ts b/packages/meetup/src/cli.ts index 54e1c90..c2fd5bb 100644 --- a/packages/meetup/src/cli.ts +++ b/packages/meetup/src/cli.ts @@ -2,28 +2,42 @@ import fs from "node:fs"; import type { NormalizedEvent } from "@coopkit/core"; import { defineCommand, runMain } from "citty"; -import { loadMeetupConfig, resolveGroupTargets } from "./config.js"; import { - type CreateMeetupDraftsResult, + type GroupTarget, + type MeetupConfig, + loadMeetupConfig, + resolveGroupTargets, +} from "./config.js"; +import { createMeetupDraft, createMeetupDraftFromFile, createMeetupDrafts, + createMeetupDraftsFromFile, } from "./create-event.js"; import { listGroups } from "./list-groups.js"; import { formatVenueKey, listVenues } from "./list-venues.js"; import { loadEnvFile } from "./load-env.js"; /** - * Write the run result as a JSON object to `outputPath` (if given) so callers - * — notably GitHub Actions — can capture the created event's id/url. Keeps the - * package free of any Actions-specific coupling: it just emits JSON. + * A per-group run's outcome, loose enough for both multi-group entry points: + * `create-from-json` never skips, while the file path can report + * `{status: "skipped", reason}` for a group already recorded in the file. */ +type MultiGroupResult = { + results: Array< + { groupUrlname: string } & ( + | { ok: true; result: SingleResult & { reason?: string } } + | { ok: false; error: string } + ) + >; +}; + /** * Collapse per-group outcomes into one status: "partial" if any group failed, - * otherwise whatever the groups agree on ("dry-run" or "created"). Reporting a - * dry run as "created" would be a lie a caller might act on. + * otherwise whatever the groups agree on ("dry-run", "created", "skipped"). + * Reporting a dry run as "created" would be a lie a caller might act on. */ -function aggregateStatus(result: CreateMeetupDraftsResult): string { +function aggregateStatus(result: MultiGroupResult): string { if (result.results.some((r) => !r.ok)) return "partial"; const statuses = new Set(result.results.map((r) => (r.ok ? r.result.status : "failed"))); return statuses.size === 1 ? ([...statuses][0] ?? "created") : "mixed"; @@ -47,9 +61,14 @@ function summarizeSingle(result: SingleResult) { : { status: result.status }; } +/** + * Write the run result as a JSON object to `outputPath` (if given) so callers + * — notably GitHub Actions — can capture the created event's id/url. Keeps the + * package free of any Actions-specific coupling: it just emits JSON. + */ function writeResultFile( outputPath: string | undefined, - result: SingleResult | CreateMeetupDraftsResult + result: SingleResult | MultiGroupResult ): void { if (!outputPath) return; @@ -61,7 +80,14 @@ function writeResultFile( status: aggregateStatus(result), groups: result.results.map((r) => r.ok - ? { groupUrlname: r.groupUrlname, ok: true, ...summarizeSingle(r.result) } + ? { + groupUrlname: r.groupUrlname, + ok: true, + ...summarizeSingle(r.result), + // Only the per-group shape carries a skip reason; the + // single-group payload stays exactly as it was. + ...(r.result.reason !== undefined ? { reason: r.result.reason } : {}), + } : { groupUrlname: r.groupUrlname, ok: false, error: r.error } ), } @@ -70,13 +96,44 @@ function writeResultFile( fs.writeFileSync(outputPath, `${JSON.stringify(payload)}\n`); } -/** Split a `--host "A,B"` value into trimmed names. */ -function parseHostArg(value: unknown): string[] | undefined { +/** + * Split a comma-separated `--host` / `--groups` value into trimmed names. + * Returns `undefined` — not `[]` — for an all-empty value such as `","`: an + * empty array is truthy and would silently read as "every group". + */ +function parseCsvArg(value: unknown): string[] | undefined { if (typeof value !== "string" || value.trim() === "") return undefined; - return value + const parts = value .split(",") .map((s) => s.trim()) .filter((s) => s !== ""); + return parts.length > 0 ? parts : undefined; +} + +const GROUPS_ARG_DESCRIPTION = + 'Group urlname(s) to create the event in, comma-separated, or "all" for every group in the config. Defaults to meetup.groupUrlname only.'; + +/** + * Resolve which groups a run targets, shared by both create commands. + * + * Without `--groups` this selects `meetup.groupUrlname` alone and reports + * `multiGroup: false`, which keeps the original single-group code path — same + * frontmatter write-back, same `--output` shape. + */ +function resolveTargets( + config: MeetupConfig, + args: { host?: string; groups?: string } +): { targets: GroupTarget[]; multiGroup: boolean } { + const hostNames = parseCsvArg(args.host); + const requested = parseCsvArg(args.groups); + const multiGroup = requested !== undefined; + const all = requested?.length === 1 && requested[0]?.toLowerCase() === "all"; + const only = multiGroup ? (all ? undefined : requested) : [config.groupUrlname]; + const targets = resolveGroupTargets(config, { + ...(only !== undefined ? { only } : {}), + ...(hostNames !== undefined ? { hostNames } : {}), + }); + return { targets, multiGroup }; } const createCmd = defineCommand({ @@ -105,6 +162,10 @@ const createCmd = defineCommand({ description: "Host name(s) from meetup.hosts, comma-separated. Defaults to meetup.defaultHosts.", }, + groups: { + type: "string", + description: GROUPS_ARG_DESCRIPTION, + }, output: { type: "string", description: @@ -114,24 +175,42 @@ const createCmd = defineCommand({ async run({ args }) { loadEnvFile(); const config = loadMeetupConfig(args.config); - const hostNames = parseHostArg(args.host); - const [target] = resolveGroupTargets(config, { - only: [config.groupUrlname], - ...(hostNames !== undefined ? { hostNames } : {}), - }); - if (!target) throw new Error("No group resolved from the config."); - const result = await createMeetupDraftFromFile({ + const { targets, multiGroup } = resolveTargets(config, args); + + // Single-group runs keep the original path: scalar event_url/event_id + // write-back and the flat --output shape. + if (!multiGroup) { + const [target] = targets; + if (!target) throw new Error("No group resolved from the config."); + const result = await createMeetupDraftFromFile({ + eventFile: args.eventFile, + groupUrlname: target.urlname, + venues: config.venues, + ...((target.timezone ?? config.timezone) !== undefined + ? { timezone: (target.timezone ?? config.timezone) as string } + : {}), + ...(target.hosts.length > 0 ? { hosts: target.hosts } : {}), + includeSpeaker: target.includeSpeaker, + dryRun: Boolean(args["dry-run"]), + }); + writeResultFile(args.output, result); + return; + } + + // `targets` carry a per-group timezone; config.timezone stays only as the + // fallback for a single-group config that names no groupTimezones. + const result = await createMeetupDraftsFromFile({ eventFile: args.eventFile, - groupUrlname: target.urlname, + groups: targets, + // Always the config's primary group, never whichever group --groups + // happens to name first: the scalars must keep pointing at one group. + primaryGroup: config.groupUrlname, venues: config.venues, - ...((target.timezone ?? config.timezone) !== undefined - ? { timezone: (target.timezone ?? config.timezone) as string } - : {}), - ...(target.hosts.length > 0 ? { hosts: target.hosts } : {}), - includeSpeaker: target.includeSpeaker, + ...(config.timezone !== undefined ? { timezone: config.timezone } : {}), dryRun: Boolean(args["dry-run"]), }); writeResultFile(args.output, result); + if (result.results.some((r) => !r.ok)) process.exitCode = 1; }, }); @@ -185,8 +264,7 @@ const createFromJsonCmd = defineCommand({ }, groups: { type: "string", - description: - 'Group urlname(s) to create the event in, comma-separated, or "all" for every group in the config. Defaults to meetup.groupUrlname only.', + description: GROUPS_ARG_DESCRIPTION, }, output: { type: "string", @@ -199,17 +277,7 @@ const createFromJsonCmd = defineCommand({ const config = loadMeetupConfig(args.config); const raw = args.file ? fs.readFileSync(args.file, "utf8") : readStdin(); const event = parseNormalizedEvent(raw); - const hostNames = parseHostArg(args.host); - const requested = parseHostArg(args.groups); - const multiGroup = requested !== undefined; - const only = - requested && !(requested.length === 1 && requested[0]?.toLowerCase() === "all") - ? requested - : undefined; - const targets = resolveGroupTargets(config, { - ...(only !== undefined ? { only } : {}), - ...(hostNames !== undefined ? { hostNames } : {}), - }); + const { targets, multiGroup } = resolveTargets(config, args); // Single-group runs keep returning the bare result, so existing callers // parsing --output see no change; --groups opts into the per-group report. diff --git a/packages/meetup/src/create-drafts-from-file.test.ts b/packages/meetup/src/create-drafts-from-file.test.ts new file mode 100644 index 0000000..c79caa4 --- /dev/null +++ b/packages/meetup/src/create-drafts-from-file.test.ts @@ -0,0 +1,296 @@ +// Multi-group file-per-event tests. Offline: dryRun stops createMeetupDraft +// after building the payload, and the per-group skip happens before any client +// is constructed — one case deliberately runs with dryRun:false to prove it. +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import matter from "gray-matter"; +import { createMeetupDraftsFromFile, recordCreatedGroupEvent } from "./create-event.js"; +import type { VenueMap } from "./venues.js"; + +const VENUES: VenueMap = { online: "online" }; + +const FRONTMATTER = [ + "---", + "title: File Event", + "date: 2026-05-09T18:00:00.000Z", + "duration: PT2H", + "venues:", + " - online", + "---", + "", + "# File Event", + "", + "This is the body.", +].join("\n"); + +const dirs: string[] = []; +function tempEventFile(contents: string): string { + const dir = mkdtempSync(join(tmpdir(), "coopkit-groups-")); + dirs.push(dir); + const file = join(dir, "event.md"); + writeFileSync(file, contents); + return file; +} + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +const quiet = () => {}; + +/** The event file with an already-recorded `meetup_events` list. */ +function withMeetupEvents(rows: Array<{ group: string; event_id: string }>): string { + const parsed = matter(FRONTMATTER); + return matter.stringify(parsed.content, { ...parsed.data, meetup_events: rows }); +} + +function statuses(results: Awaited>["results"]) { + return results.map((r) => (r.ok ? r.result.status : "failed")); +} + +const THREE_GROUPS = [ + { urlname: "chicago-c-cpp-users-group" }, + { urlname: "cpp-serbia" }, + { urlname: "CPPTORONTO" }, +]; + +describe("createMeetupDraftsFromFile (dry-run)", () => { + it("attempts every group when none is recorded yet, in order", async () => { + const file = tempEventFile(FRONTMATTER); + const before = readFileSync(file, "utf8"); + + const { results } = await createMeetupDraftsFromFile({ + eventFile: file, + groups: THREE_GROUPS, + primaryGroup: "chicago-c-cpp-users-group", + venues: VENUES, + dryRun: true, + log: quiet, + }); + + expect(results.map((r) => r.groupUrlname)).toEqual([ + "chicago-c-cpp-users-group", + "cpp-serbia", + "CPPTORONTO", + ]); + expect(statuses(results)).toEqual(["dry-run", "dry-run", "dry-run"]); + // A dry run must not touch the file at all. + expect(readFileSync(file, "utf8")).toBe(before); + }); + + it("skips a group already recorded in meetup_events and keeps request order", async () => { + const file = tempEventFile(withMeetupEvents([{ group: "cpp-serbia", event_id: "313413133" }])); + + const { results } = await createMeetupDraftsFromFile({ + eventFile: file, + groups: THREE_GROUPS, + primaryGroup: "chicago-c-cpp-users-group", + venues: VENUES, + dryRun: true, + log: quiet, + }); + + expect(results.map((r) => r.groupUrlname)).toEqual([ + "chicago-c-cpp-users-group", + "cpp-serbia", + "CPPTORONTO", + ]); + expect(statuses(results)).toEqual(["dry-run", "skipped", "dry-run"]); + const skipped = results[1]; + const reason = skipped?.ok && skipped.result.status === "skipped" ? skipped.result.reason : ""; + expect(reason).toContain("cpp-serbia"); + expect(reason).toContain("313413133"); + }); + + it("matches a recorded group case-insensitively", async () => { + const file = tempEventFile(withMeetupEvents([{ group: "CPPTORONTO", event_id: "314900001" }])); + const { results } = await createMeetupDraftsFromFile({ + eventFile: file, + groups: [{ urlname: "cpptoronto" }], + primaryGroup: "chicago-c-cpp-users-group", + venues: VENUES, + dryRun: true, + log: quiet, + }); + expect(statuses(results)).toEqual(["skipped"]); + }); + + it("skips everything without credentials or a client when all groups are recorded", async () => { + const file = tempEventFile( + withMeetupEvents([ + { group: "chicago-c-cpp-users-group", event_id: "313413133" }, + { group: "cpp-serbia", event_id: "314900001" }, + ]) + ); + const before = readFileSync(file, "utf8"); + + // dryRun:false — reaching Meetup would need credentials this test has none + // of, so completing at all proves the skip precedes any client use. + const { results } = await createMeetupDraftsFromFile({ + eventFile: file, + groups: [{ urlname: "chicago-c-cpp-users-group" }, { urlname: "cpp-serbia" }], + primaryGroup: "chicago-c-cpp-users-group", + venues: VENUES, + dryRun: false, + log: quiet, + }); + + expect(statuses(results)).toEqual(["skipped", "skipped"]); + expect(readFileSync(file, "utf8")).toBe(before); + }); + + it("attempts a duplicated group only once", async () => { + const file = tempEventFile(FRONTMATTER); + const lines: string[] = []; + + const { results } = await createMeetupDraftsFromFile({ + eventFile: file, + groups: [{ urlname: "cpp-serbia" }, { urlname: "CPP-Serbia" }], + primaryGroup: "cpp-serbia", + venues: VENUES, + dryRun: true, + log: (m) => lines.push(m), + }); + + expect(results.map((r) => r.groupUrlname)).toEqual(["cpp-serbia"]); + expect(lines.filter((l) => l.startsWith("[1/1]")).length).toBe(1); + expect(lines.some((l) => l.startsWith("[2/"))).toBe(false); + }); + + it("skips the group named by a legacy event_url scalar and attempts the rest", async () => { + const legacy = FRONTMATTER.replace( + "duration: PT2H", + [ + "duration: PT2H", + "event_url: 'https://www.meetup.com/cpp-serbia/events/313413133/'", + "event_id: 313413133", + ].join("\n") + ); + const file = tempEventFile(legacy); + + const { results } = await createMeetupDraftsFromFile({ + eventFile: file, + groups: THREE_GROUPS, + primaryGroup: "chicago-c-cpp-users-group", + venues: VENUES, + dryRun: true, + log: quiet, + }); + + // No migration needed: the scalars alone say the event exists in cpp-serbia. + expect(statuses(results)).toEqual(["dry-run", "skipped", "dry-run"]); + }); + + it("rejects when the event file does not exist", async () => { + await expect( + createMeetupDraftsFromFile({ + eventFile: join(tmpdir(), "coopkit-does-not-exist-xyz.md"), + groups: THREE_GROUPS, + venues: VENUES, + dryRun: true, + log: quiet, + }) + ).rejects.toThrow(/not found/); + }); +}); + +describe("recordCreatedGroupEvent", () => { + const parsed = matter(FRONTMATTER); + const primary = { + group: "cpp-serbia", + event_id: "313413133", + event_url: "https://www.meetup.com/cpp-serbia/events/313413133/", + }; + const secondary = { + group: "chicago-c-cpp-users-group", + event_id: "314900001", + event_url: "https://www.meetup.com/chicago-c-cpp-users-group/events/314900001/", + }; + + it("writes the scalars and one meetup_events row for the primary group", () => { + const updated = recordCreatedGroupEvent({ + content: parsed.content, + data: { ...parsed.data }, + entry: primary, + existing: [], + primaryGroup: "cpp-serbia", + }); + + // Literally the grep the reusable workflow uses to decide whether the run + // did any work: a top-level, column-0, single-line event_url scalar. + expect(updated).toMatch(/^event_url:/m); + expect(updated.match(/^event_url:/gm)?.length).toBe(1); + expect(updated).toMatch(/^event_id:/m); + + const written = matter(updated); + expect(written.data.event_id).toBe("313413133"); + expect(written.data.event_url).toBe(primary.event_url); + expect(written.data.meetup_events).toEqual([primary]); + expect(written.content.trim()).toBe(parsed.content.trim()); + }); + + it("records a non-primary group without touching the scalars", () => { + const updated = recordCreatedGroupEvent({ + content: parsed.content, + data: { ...parsed.data }, + entry: secondary, + existing: [], + primaryGroup: "cpp-serbia", + }); + + expect(updated).not.toMatch(/^event_url:/m); + expect(updated).not.toMatch(/^event_id:/m); + const written = matter(updated); + expect(written.data.meetup_events).toEqual([secondary]); + }); + + it("keeps the primary first and the scalars intact when adding a secondary", () => { + const legacy = matter( + recordCreatedGroupEvent({ + content: parsed.content, + data: { ...parsed.data }, + entry: primary, + existing: [], + primaryGroup: "cpp-serbia", + }) + ); + + const updated = matter( + recordCreatedGroupEvent({ + content: legacy.content, + data: { ...legacy.data }, + entry: secondary, + existing: [primary], + primaryGroup: "cpp-serbia", + }) + ); + + expect(updated.data.meetup_events).toEqual([primary, secondary]); + expect(updated.data.event_id).toBe(primary.event_id); + expect(updated.data.event_url).toBe(primary.event_url); + }); + + it("replaces rather than appends when the same group is recorded again", () => { + const refreshed = { + group: "CPP-Serbia", + event_id: "999999999", + event_url: "https://www.meetup.com/cpp-serbia/events/999999999/", + }; + const updated = matter( + recordCreatedGroupEvent({ + content: parsed.content, + data: { ...parsed.data }, + entry: refreshed, + existing: [primary, secondary], + primaryGroup: "cpp-serbia", + }) + ); + + expect(updated.data.meetup_events).toEqual([refreshed, secondary]); + expect(updated.data.event_id).toBe("999999999"); + }); +}); diff --git a/packages/meetup/src/create-event.ts b/packages/meetup/src/create-event.ts index c4a1cd5..b454a33 100644 --- a/packages/meetup/src/create-event.ts +++ b/packages/meetup/src/create-event.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import type { EventFrontmatter, NormalizedEvent } from "@coopkit/core"; +import type { EventFrontmatter, NormalizedEvent, PublishedEventRef } from "@coopkit/core"; import { frontmatterToNormalizedEvent } from "@coopkit/core"; import matter from "gray-matter"; import { @@ -11,9 +11,12 @@ import { } from "./client.js"; import { type CreateEventPayload, + type CreatedEventBookkeeping, buildCreateEventPayload, + createdGroupEvents, detectContentType, isEventAlreadyCreated, + mergeCreatedGroupEvents, stripLeadingHeading, } from "./payload.js"; import { type VenueMap, resolveVenueId } from "./venues.js"; @@ -161,6 +164,8 @@ export interface CreateMeetupDraftOptions { */ onCreated?: (info: { event: NormalizedEvent; + /** The group the event was created in — the caller may be creating in several. */ + groupUrlname: string; result: { eventId: string; eventUrl: string; photoAttached: boolean }; }) => Promise | void; } @@ -223,11 +228,96 @@ export async function createMeetupDraft( const result = { eventId: created.id, eventUrl: created.eventUrl, photoAttached }; if (options.onCreated) { - await options.onCreated({ event: options.event, result }); + await options.onCreated({ + event: options.event, + groupUrlname: options.groupUrlname, + result, + }); } return { status: "created", ...result }; } +/** Frontmatter as read off an event file, including the bookkeeping fields. */ +type EventFileFrontmatter = EventFrontmatter & { + event_id?: string | number; + meetup_events?: unknown; +}; + +interface ReadEventFile { + parsed: matter.GrayMatterFile; + fm: EventFileFrontmatter; + event: NormalizedEvent; +} + +/** + * Read an event markdown file and normalize it. No idempotency check here — + * both file entry points do that themselves, against different scopes (one + * group vs. a set of them). + */ +function readEventFile(eventFile: string): ReadEventFile { + if (!fs.existsSync(eventFile)) { + throw new Error(`Event file not found: ${eventFile}`); + } + + const parsed = matter(fs.readFileSync(eventFile, "utf8")); + const fm = parsed.data as EventFileFrontmatter; + const id = path.basename(eventFile).replace(/\.md$/, ""); + const event = frontmatterToNormalizedEvent(id, fm, stripLeadingHeading(parsed.content)); + return { parsed, fm, event }; +} + +/** Human-readable reason for skipping a group that is already recorded. */ +function alreadyCreatedReason(eventFile: string, entry: PublishedEventRef): string { + return `${eventFile} already has event_id=${entry.event_id} for group ${entry.group}; nothing to do.`; +} + +export interface RecordCreatedGroupEventInput { + /** Markdown body, as returned by gray-matter. */ + content: string; + /** Parsed frontmatter. Not mutated; the updated copy goes into the output. */ + data: Record; + /** The group + ids just created. */ + entry: PublishedEventRef; + /** Groups already recorded in this file, in the order they should stay. */ + existing: readonly PublishedEventRef[]; + /** + * The config's primary group. Only a create in *this* group updates the + * `event_url` / `event_id` scalars. + */ + primaryGroup?: string; +} + +/** + * Record one created group event in an event file's frontmatter and return the + * new file contents. Pure: the caller does the writing, which is what makes + * the merge testable without a filesystem or a network. + * + * The scalars are written **only** for the primary group. Putting a secondary + * group's id in `event_url` / `event_id` would make a later plain `create` skip + * the file and so never create the primary event at all, and would point an + * adopter site's "Register on Meetup" link at the wrong group. `meetup_events` + * always gets the row, primary or not, and always includes the primary — it is + * the complete record. + */ +export function recordCreatedGroupEvent(input: RecordCreatedGroupEventInput): string { + const { content, data, entry, existing, primaryGroup } = input; + const isPrimary = + primaryGroup !== undefined && entry.group.toLowerCase() === primaryGroup.toLowerCase(); + + const next: Record = { ...data }; + if (isPrimary) { + if (entry.event_url !== undefined) next.event_url = entry.event_url; + next.event_id = entry.event_id; + } + next.meetup_events = mergeCreatedGroupEvents(existing, entry).map((e) => ({ + group: e.group, + event_id: e.event_id, + ...(e.event_url !== undefined ? { event_url: e.event_url } : {}), + })); + + return matter.stringify(content, next); +} + export interface CreateMeetupDraftFromFileOptions { /** Path to the event markdown file (cppserbia-style: one event per file). */ eventFile: string; @@ -261,23 +351,27 @@ export async function createMeetupDraftFromFile( ): Promise { const log = options.log ?? ((m) => console.error(m)); - if (!fs.existsSync(options.eventFile)) { - throw new Error(`Event file not found: ${options.eventFile}`); - } - - const raw = fs.readFileSync(options.eventFile, "utf8"); - const parsed = matter(raw); - const fm = parsed.data as EventFrontmatter & { event_id?: string | number }; + const { parsed, fm, event } = readEventFile(options.eventFile); - if (isEventAlreadyCreated(fm.event_id)) { - const reason = `${options.eventFile} already has event_id=${fm.event_id}; nothing to do.`; + // Same dedup rule as the multi-group entry point, scoped to this one group: + // a group recorded in `meetup_events` counts as created even when the + // scalars are empty (they only ever mirror the primary group). + const already = createdGroupEvents(fm, { + assumedGroup: options.groupUrlname, + source: options.eventFile, + }); + const recorded = already.get(options.groupUrlname.toLowerCase()); + if (recorded) { + // Keep the original wording when the scalar is what matched, so callers + // grepping for it (and the existing workflows) see no change. + const reason = + isEventAlreadyCreated(fm.event_id) && String(fm.event_id).trim() === recorded.event_id + ? `${options.eventFile} already has event_id=${fm.event_id}; nothing to do.` + : alreadyCreatedReason(options.eventFile, recorded); log(`[skip] ${reason}`); return { status: "skipped", reason }; } - const id = path.basename(options.eventFile).replace(/\.md$/, ""); - const event = frontmatterToNormalizedEvent(id, fm, stripLeadingHeading(parsed.content)); - return createMeetupDraft({ event, groupUrlname: options.groupUrlname, @@ -384,3 +478,159 @@ export async function createMeetupDrafts( log(`Done: ${results.length - failed.length}/${results.length} group(s) succeeded${failedNote}`); return { results }; } + +export interface CreateMeetupDraftsFromFileOptions { + /** Path to the event markdown file (cppserbia-style: one event per file). */ + eventFile: string; + /** + * Groups to create the event in, in order, each with its own hosts and its own + * timezone. The timezone must be per group: Meetup reads `startDateTime` as + * wall time in the *receiving* group's zone, so one shared wall time would put + * the event at a different instant in each group. + */ + groups: Array<{ + urlname: string; + hosts?: number[]; + includeSpeaker?: boolean; + timezone?: string; + }>; + /** + * The config's primary group — the only one whose ids are mirrored into the + * `event_url` / `event_id` scalars. Deliberately independent of `groups`, so + * "primary" never depends on which groups a given run happened to select. + */ + primaryGroup?: string; + venues: VenueMap; + /** Fallback IANA timezone for groups that name none. See `BuildPayloadInput.timezone`. */ + timezone?: string; + dryRun?: boolean; + credentials?: MeetupCredentials; + log?: (message: string) => void; +} + +export type CreateMeetupDraftsFromFileResult = { + results: Array< + { groupUrlname: string } & ( + | { ok: true; result: CreateMeetupDraftFromFileResult } + | { ok: false; error: string } + ) + >; +}; + +/** + * Create the event described by one markdown file as a Draft in several groups + * — the file-per-event counterpart of `createMeetupDrafts`. + * + * Idempotent **per group**: `meetup_events` in the frontmatter records every + * group the event exists in, and a recorded group is reported as + * `{status: "skipped"}` without any API call. That makes a retry after a + * partial failure safe — it recreates only the groups that are missing. + * + * The file is written back **inside** `onCreated`, once per group, so a failure + * halfway through still leaves the successful groups recorded on disk. When + * every requested group is already recorded, this returns before constructing a + * client or touching the file, so "the file did not change" keeps meaning "no + * work was done". + */ +export async function createMeetupDraftsFromFile( + options: CreateMeetupDraftsFromFileOptions +): Promise { + const log = options.log ?? ((m) => console.error(m)); + const { parsed, fm, event } = readEventFile(options.eventFile); + + // Meetup urlnames are case-insensitive, so asking for the same group twice + // under different casing must still create exactly one draft. + const requested: CreateMeetupDraftsFromFileOptions["groups"] = []; + const seen = new Set(); + for (const group of options.groups) { + const key = group.urlname.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + requested.push(group); + } + + const bookkeeping = { + ...(options.primaryGroup !== undefined ? { assumedGroup: options.primaryGroup } : {}), + source: options.eventFile, + }; + let already = createdGroupEvents(fm, bookkeeping); + + const byGroup = new Map(); + const todo: CreateMeetupDraftsFromFileOptions["groups"] = []; + for (const group of requested) { + const recorded = already.get(group.urlname.toLowerCase()); + if (recorded) { + const reason = alreadyCreatedReason(options.eventFile, recorded); + log(`[skip] ${reason}`); + byGroup.set(group.urlname.toLowerCase(), { + groupUrlname: group.urlname, + ok: true, + result: { status: "skipped", reason }, + }); + continue; + } + todo.push(group); + } + + // Nothing to do: return before `createMeetupDrafts` (which rejects an empty + // group list), before any client is built, and without writing the file. + if (todo.length === 0) { + return { results: requested.map((g) => orderedResult(byGroup, g.urlname)) }; + } + + let content = parsed.content; + let data = parsed.data as Record; + + const created = await createMeetupDrafts({ + event, + groups: todo, + venues: options.venues, + ...(options.timezone !== undefined ? { timezone: options.timezone } : {}), + ...(options.dryRun !== undefined ? { dryRun: options.dryRun } : {}), + ...(options.credentials !== undefined ? { credentials: options.credentials } : {}), + log, + onCreated: ({ groupUrlname, result }) => { + const entry: PublishedEventRef = { + group: groupUrlname, + event_id: result.eventId, + ...(result.eventUrl !== "" ? { event_url: result.eventUrl } : {}), + }; + const updated = recordCreatedGroupEvent({ + content, + data, + entry, + existing: [...already.values()], + ...(options.primaryGroup !== undefined ? { primaryGroup: options.primaryGroup } : {}), + }); + fs.writeFileSync(options.eventFile, updated); + + // Re-read our own output so the next group merges onto exactly what is + // on disk, rather than a hand-maintained copy that could drift. + const reloaded = matter(updated); + content = reloaded.content; + data = reloaded.data as Record; + already = createdGroupEvents(data as CreatedEventBookkeeping, bookkeeping); + log(`[updated] ${options.eventFile} with meetup_events entry for ${groupUrlname}`); + }, + }); + + for (const result of created.results) { + byGroup.set(result.groupUrlname.toLowerCase(), result); + } + + return { results: requested.map((g) => orderedResult(byGroup, g.urlname)) }; +} + +/** Look up a group's outcome, so results come back in the requested order. */ +function orderedResult( + byGroup: Map, + urlname: string +): CreateMeetupDraftsFromFileResult["results"][number] { + return ( + byGroup.get(urlname.toLowerCase()) ?? { + groupUrlname: urlname, + ok: false, + error: "No result reported for this group.", + } + ); +} diff --git a/packages/meetup/src/index.ts b/packages/meetup/src/index.ts index 3a8b383..54825bd 100644 --- a/packages/meetup/src/index.ts +++ b/packages/meetup/src/index.ts @@ -11,25 +11,38 @@ export type { VenueId, VenueMap } from "./venues.js"; export { buildCreateEventPayload, buildCreateEventPayloadWithMap, + createdGroupEvents, detectContentType, + groupUrlnameFromEventUrl, isEventAlreadyCreated, + mergeCreatedGroupEvents, stripLeadingHeading, wallTimeInZone, } from "./payload.js"; -export type { BuildPayloadInput, CreateEventPayload } from "./payload.js"; +export type { + BuildPayloadInput, + CreateEventPayload, + CreatedEventBookkeeping, + CreatedGroupEventsOptions, +} from "./payload.js"; export { createMeetupDraft, createMeetupDraftFromFile, createMeetupDrafts, + createMeetupDraftsFromFile, + recordCreatedGroupEvent, } from "./create-event.js"; export type { CreateMeetupDraftFromFileOptions, CreateMeetupDraftFromFileResult, CreateMeetupDraftOptions, CreateMeetupDraftResult, + CreateMeetupDraftsFromFileOptions, + CreateMeetupDraftsFromFileResult, CreateMeetupDraftsOptions, CreateMeetupDraftsResult, + RecordCreatedGroupEventInput, } from "./create-event.js"; export { fetchAllVenues, formatVenueKey, listVenues } from "./list-venues.js"; diff --git a/packages/meetup/src/payload.test.ts b/packages/meetup/src/payload.test.ts index c588422..bfea792 100644 --- a/packages/meetup/src/payload.test.ts +++ b/packages/meetup/src/payload.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from "bun:test"; import type { NormalizedEvent } from "@coopkit/core"; import { buildCreateEventPayload, + createdGroupEvents, detectContentType, + groupUrlnameFromEventUrl, isEventAlreadyCreated, + mergeCreatedGroupEvents, stripLeadingHeading, wallTimeInZone, } from "./payload.js"; @@ -402,3 +405,159 @@ describe("buildCreateEventPayload speakerDetails", () => { expect("speakerDetails" in payload).toBe(false); }); }); + +describe("groupUrlnameFromEventUrl", () => { + it("extracts the urlname from a canonical Meetup event URL", () => { + expect(groupUrlnameFromEventUrl("https://www.meetup.com/cpp-serbia/events/313413133/")).toBe( + "cpp-serbia" + ); + }); + + it("accepts http and a missing www", () => { + expect(groupUrlnameFromEventUrl("http://meetup.com/cpp-serbia/events/313413133")).toBe( + "cpp-serbia" + ); + }); + + it("keeps the urlname's case as written", () => { + expect(groupUrlnameFromEventUrl("https://www.meetup.com/CPPTORONTO/events/313413133/")).toBe( + "CPPTORONTO" + ); + }); + + it("is undefined for a non-Meetup URL", () => { + expect(groupUrlnameFromEventUrl("https://example.com/cpp-serbia/events/1/")).toBeUndefined(); + }); + + it("is undefined for a Meetup URL that is not an event URL", () => { + expect(groupUrlnameFromEventUrl("https://www.meetup.com/cpp-serbia/")).toBeUndefined(); + expect(groupUrlnameFromEventUrl("https://www.meetup.com/cpp-serbia/events/")).toBeUndefined(); + }); + + it("is undefined for non-strings", () => { + expect(groupUrlnameFromEventUrl(undefined)).toBeUndefined(); + expect(groupUrlnameFromEventUrl(313413133)).toBeUndefined(); + }); +}); + +describe("createdGroupEvents", () => { + it("is empty for a file with no bookkeeping at all", () => { + expect(createdGroupEvents({}).size).toBe(0); + }); + + it("attributes a legacy scalar pair to the group named in event_url", () => { + const created = createdGroupEvents( + { + event_url: "https://www.meetup.com/cpp-serbia/events/313413133/", + event_id: 313413133, + }, + { assumedGroup: "chicago-c-cpp-users-group" } + ); + + // The URL wins: assumedGroup is only a fallback, and trusting it here + // would mark the wrong group as done. + expect([...created.keys()]).toEqual(["cpp-serbia"]); + expect(created.get("cpp-serbia")).toEqual({ + group: "cpp-serbia", + event_id: "313413133", + event_url: "https://www.meetup.com/cpp-serbia/events/313413133/", + }); + }); + + it("falls back to assumedGroup when there is no usable event_url", () => { + const created = createdGroupEvents({ event_id: "313413133" }, { assumedGroup: "cpp-serbia" }); + expect(created.get("cpp-serbia")?.event_id).toBe("313413133"); + expect(created.get("cpp-serbia")?.event_url).toBeUndefined(); + }); + + it("ignores a scalar pair it cannot attribute to any group", () => { + expect(createdGroupEvents({ event_id: "313413133" }).size).toBe(0); + }); + + it("ignores the template placeholder event_id", () => { + const created = createdGroupEvents( + { event_id: "" }, + { assumedGroup: "cpp-serbia" } + ); + expect(created.size).toBe(0); + }); + + it("finds a group case-insensitively", () => { + const created = createdGroupEvents({ + meetup_events: [{ group: "CPPTORONTO", event_id: "314900001" }], + }); + expect(created.get("cpptoronto")?.group).toBe("CPPTORONTO"); + }); + + it("lets an explicit meetup_events entry win over the inferred scalar", () => { + const created = createdGroupEvents({ + event_url: "https://www.meetup.com/cpp-serbia/events/313413133/", + event_id: 313413133, + meetup_events: [{ group: "cpp-serbia", event_id: "999999999" }], + }); + expect(created.size).toBe(1); + expect(created.get("cpp-serbia")?.event_id).toBe("999999999"); + }); + + it("keeps both a listed group and the scalar's own group", () => { + const created = createdGroupEvents({ + event_url: "https://www.meetup.com/cpp-serbia/events/313413133/", + event_id: 313413133, + meetup_events: [{ group: "CPPTORONTO", event_id: "314900001" }], + }); + expect([...created.keys()]).toEqual(["cpptoronto", "cpp-serbia"]); + }); + + it("throws, naming the source, for a malformed meetup_events entry", () => { + expect(() => + createdGroupEvents( + { meetup_events: [{ group: "cpp-serbia", event_id: "not-an-id" }] }, + { source: "events/2026-05-09-test.md" } + ) + ).toThrow(/meetup_events\[0\] in events\/2026-05-09-test\.md/); + + expect(() => + createdGroupEvents( + { meetup_events: [{ event_id: "313413133" }] }, + { source: "events/2026-05-09-test.md" } + ) + ).toThrow(/events\/2026-05-09-test\.md is missing a non-empty `group`/); + + expect(() => + createdGroupEvents({ meetup_events: ["cpp-serbia"] }, { source: "events/x.md" }) + ).toThrow(/must be an object/); + + expect(() => createdGroupEvents({ meetup_events: "cpp-serbia" })).toThrow(/must be a list/); + }); +}); + +describe("mergeCreatedGroupEvents", () => { + const serbia = { group: "cpp-serbia", event_id: "313413133" }; + const toronto = { group: "CPPTORONTO", event_id: "314900001" }; + + it("appends a group that is not recorded yet", () => { + expect(mergeCreatedGroupEvents([serbia], toronto)).toEqual([serbia, toronto]); + }); + + it("replaces a recorded group in place, matching case-insensitively", () => { + const merged = mergeCreatedGroupEvents([serbia, toronto], { + group: "cpptoronto", + event_id: "999999999", + event_url: "https://www.meetup.com/cpptoronto/events/999999999/", + }); + expect(merged).toEqual([ + serbia, + { + group: "cpptoronto", + event_id: "999999999", + event_url: "https://www.meetup.com/cpptoronto/events/999999999/", + }, + ]); + }); + + it("does not mutate the input list", () => { + const existing = [serbia]; + mergeCreatedGroupEvents(existing, toronto); + expect(existing).toEqual([serbia]); + }); +}); diff --git a/packages/meetup/src/payload.ts b/packages/meetup/src/payload.ts index ff8caec..2564d51 100644 --- a/packages/meetup/src/payload.ts +++ b/packages/meetup/src/payload.ts @@ -1,4 +1,4 @@ -import type { NormalizedEvent } from "@coopkit/core"; +import type { NormalizedEvent, PublishedEventRef } from "@coopkit/core"; import { type SpeakerDetailsInput, speakerDetailsFrom } from "./speaker.js"; import { type VenueId, type VenueMap, resolveVenueId } from "./venues.js"; @@ -58,6 +58,123 @@ export function isEventAlreadyCreated(eventId: unknown): boolean { return /^\d+$/.test(s); } +const MEETUP_EVENT_URL_RE = /^https?:\/\/(?:www\.)?meetup\.com\/([^/]+)\/events\/\d+/i; + +/** + * Extract the group urlname from a Meetup event URL + * (`https://www.meetup.com//events//`). + * + * This is what lets a file written before per-group bookkeeping existed still + * say which group its `event_url` / `event_id` scalars belong to, so no + * migration step is needed: the legacy pair is attributed to its own group and + * that group is skipped on the next run. + */ +export function groupUrlnameFromEventUrl(eventUrl: unknown): string | undefined { + if (typeof eventUrl !== "string") return undefined; + return MEETUP_EVENT_URL_RE.exec(eventUrl.trim())?.[1]; +} + +/** The bookkeeping fields `createdGroupEvents` reads out of frontmatter. */ +export interface CreatedEventBookkeeping { + event_id?: string | number; + event_url?: unknown; + meetup_events?: unknown; +} + +export interface CreatedGroupEventsOptions { + /** + * Group to attribute a legacy `event_id` scalar to when `event_url` is + * missing or is not a Meetup event URL. Typically the config's primary group. + */ + assumedGroup?: string; + /** File path (or similar) named in error messages. */ + source?: string; +} + +/** + * Which groups this event has already been created in, keyed by **lower-cased** + * group urlname — Meetup urlnames are case-insensitive, and a Pro network can + * report a group as `CPPTORONTO` where a config says `cpptoronto`. + * + * Reads `meetup_events`, then folds in the legacy `event_url` / `event_id` + * scalar pair attributed via `groupUrlnameFromEventUrl` (falling back to + * `options.assumedGroup`). An explicit `meetup_events` entry always wins over + * the inferred one. + * + * Throws on a malformed `meetup_events` entry rather than ignoring it: + * silently dropping a hand-edited typo would silently create a duplicate + * draft, which is the exact failure this record exists to prevent. + */ +export function createdGroupEvents( + fm: CreatedEventBookkeeping, + options: CreatedGroupEventsOptions = {} +): Map { + const where = options.source !== undefined ? ` in ${options.source}` : ""; + const created = new Map(); + + const list = fm.meetup_events; + if (list !== undefined && list !== null) { + if (!Array.isArray(list)) { + throw new Error(`meetup_events${where} must be a list of {group, event_id} entries.`); + } + for (const [index, raw] of list.entries()) { + const at = `meetup_events[${index}]${where}`; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new Error(`${at} must be an object with \`group\` and \`event_id\`.`); + } + const entry = raw as { group?: unknown; event_id?: unknown; event_url?: unknown }; + if (typeof entry.group !== "string" || entry.group.trim() === "") { + throw new Error(`${at} is missing a non-empty \`group\`.`); + } + if (!isEventAlreadyCreated(entry.event_id)) { + throw new Error( + `${at} has \`event_id\` ${JSON.stringify(entry.event_id)}, which is not a numeric event id.` + ); + } + const group = entry.group.trim(); + created.set(group.toLowerCase(), { + group, + event_id: String(entry.event_id).trim(), + ...(typeof entry.event_url === "string" && entry.event_url !== "" + ? { event_url: entry.event_url } + : {}), + }); + } + } + + if (isEventAlreadyCreated(fm.event_id)) { + const group = groupUrlnameFromEventUrl(fm.event_url) ?? options.assumedGroup; + if (group !== undefined && group !== "" && !created.has(group.toLowerCase())) { + created.set(group.toLowerCase(), { + group, + event_id: String(fm.event_id).trim(), + ...(typeof fm.event_url === "string" && fm.event_url !== "" + ? { event_url: fm.event_url } + : {}), + }); + } + } + + return created; +} + +/** + * Insert-or-replace `entry` in `existing`, matching `group` + * case-insensitively and preserving the existing order. Re-running a group + * that is already recorded refreshes its row instead of appending a second one. + */ +export function mergeCreatedGroupEvents( + existing: readonly PublishedEventRef[], + entry: PublishedEventRef +): PublishedEventRef[] { + const key = entry.group.toLowerCase(); + const index = existing.findIndex((e) => e.group.toLowerCase() === key); + if (index === -1) return [...existing, entry]; + const merged = [...existing]; + merged[index] = entry; + return merged; +} + export function stripLeadingHeading(body: string): string { const trimmed = body.replace(/^\s+/, ""); if (!trimmed.startsWith("# ")) return trimmed;