diff --git a/apps/web/src/lib/components/CreateEventModal.svelte b/apps/web/src/lib/components/CreateEventModal.svelte index 3e6543f..66a6986 100644 --- a/apps/web/src/lib/components/CreateEventModal.svelte +++ b/apps/web/src/lib/components/CreateEventModal.svelte @@ -181,7 +181,7 @@ Import event from somewhere else
- Paste a Luma, Meetup, Eventbrite, Partiful or Resident Advisor link. + Paste a Luma, Meetup, Eventbrite, Partiful, guild.host or Resident Advisor link.
diff --git a/apps/web/src/lib/import/guild.ts b/apps/web/src/lib/import/guild.ts new file mode 100644 index 0000000..87c7291 --- /dev/null +++ b/apps/web/src/lib/import/guild.ts @@ -0,0 +1,84 @@ +import { FETCH_HEADERS, fetchImageAsDataUrl } from './http'; +import type { EventImporter } from './types'; +import * as v from 'valibot'; + +const GUILD_API_BASE = 'https://guild.host/api/next'; +const GUILD_EVENT_REGEX = /^\/events\/([^/]+)/; + +const GuildEventSchema = v.object({ + slug: v.string(), + fullUrl: v.pipe(v.string(), v.url()), + name: v.string(), + description: v.string(), + startAt: v.string(), + endAt: v.string(), + timeZone: v.string(), + hasVenue: v.boolean(), + hasExternalUrl: v.boolean(), + createdAt: v.pipe(v.string(), v.toDate()), + uploadedSocialCard: v.optional( + v.object({ + url: v.pipe(v.string(), v.url()) + }) + ), + generatedSocialCardURL: v.pipe(v.string(), v.url()) +}); + +function extractGuildEventSlug(rawUrl: string) { + const url = URL.parse(rawUrl); + if (!url || (url.hostname !== 'guild.host' && url.hostname !== 'www.guild.host')) return null; + const m = url.pathname.match(GUILD_EVENT_REGEX); + if (!m) return null; + return m[1]; +} + +/** + * Importer for Guild (guild.host). Events are fetched via the Guild API + * at https://guild.host/api/next/events/. + * + * Note: The Guild API does not expose venue/location data. If the user wants + * to accept RSVPs on atmo, they will need to add the location manually after + * import. + */ +export const guildImporter: EventImporter = { + name: 'guild', + accept(ctx) { + return !!extractGuildEventSlug(ctx.url); + }, + async parseData(ctx) { + const slug = extractGuildEventSlug(ctx.url); + if (!slug) return null; + + const res = await fetch(`${GUILD_API_BASE}/events/${slug}`, { + headers: { + ...FETCH_HEADERS, + Accept: 'application/json' + } + }); + + if (!res.ok) { + throw new Error('failed to fetch guild event', { cause: res }); + } + + const data = v.parse(GuildEventSchema, await res.json()); + + return { + source: data.fullUrl, + name: data.name, + description: data.description, + timezone: data.timeZone, + startsAt: data.startAt, + endsAt: data.endAt, + imageDataUrl: await fetchImageAsDataUrl( + data.uploadedSocialCard?.url ?? data.generatedSocialCardURL + ), + links: [{ uri: data.fullUrl, name: 'Event page' }], + mode: + data.hasExternalUrl && data.hasVenue + ? 'hybrid' + : data.hasExternalUrl + ? 'virtual' + : 'inperson' + }; + } +}; diff --git a/apps/web/src/lib/import/index.ts b/apps/web/src/lib/import/index.ts index 7d344f0..3c1a644 100644 --- a/apps/web/src/lib/import/index.ts +++ b/apps/web/src/lib/import/index.ts @@ -4,13 +4,19 @@ import { FETCH_HEADERS, MAX_BYTES, readLimited } from './http'; import { racoImporter } from './raco'; import { icalImporter } from './ical'; import { webpageImporter } from './webpage'; +import { guildImporter } from './guild'; /** * Registry of source-specific importers, tried in order. Put the most specific * (host-matched, no fetch) first and the generic HTML fallback last. To support * a new platform, add a module exporting an `EventImporter` and list it here. */ -export const importers: EventImporter[] = [racoImporter, icalImporter, webpageImporter]; +export const importers: EventImporter[] = [ + racoImporter, + icalImporter, + guildImporter, + webpageImporter +]; /** * Run the import pipeline for a URL. The first importer whose `accept()` returns