diff --git a/apps/web/src/lib/components/CreateEventModal.svelte b/apps/web/src/lib/components/CreateEventModal.svelte
index e049613..3e6543f 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 or Partiful link.
+ Paste a Luma, Meetup, Eventbrite, Partiful or Resident Advisor link.
diff --git a/apps/web/src/lib/import/http.ts b/apps/web/src/lib/import/http.ts
new file mode 100644
index 0000000..5e21e15
--- /dev/null
+++ b/apps/web/src/lib/import/http.ts
@@ -0,0 +1,92 @@
+export const FETCH_HEADERS = {
+ 'User-Agent': 'atmo.rsvp/0.1 (+https://atmo.rsvp)',
+ Accept: 'text/html,text/calendar,application/json;q=0.9,*/*;q=0.8'
+};
+
+export const MAX_BYTES = 2 * 1024 * 1024;
+// 3 MB raw cap → ~4 MB base64. Most event cover images are well under this; we
+// stash the result in sessionStorage on the client, which has its own limits.
+export const MAX_IMAGE_BYTES = 3 * 1024 * 1024;
+
+/** Read a response body as text, stopping once `max` bytes have been consumed. */
+export async function readLimited(res: Response, max: number): Promise {
+ const reader = res.body?.getReader();
+ if (!reader) return await res.text();
+ const decoder = new TextDecoder();
+ let received = 0;
+ let out = '';
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ received += value.byteLength;
+ if (received > max) {
+ out += decoder.decode(value.subarray(0, Math.max(0, max - (received - value.byteLength))));
+ try {
+ await reader.cancel();
+ } catch {
+ /* ignore */
+ }
+ break;
+ }
+ out += decoder.decode(value, { stream: true });
+ }
+ out += decoder.decode();
+ return out;
+}
+
+/** Fetch an image and return it as a base64 data URL, or undefined on failure / oversize. */
+export async function fetchImageAsDataUrl(url: string): Promise {
+ try {
+ const res = await fetch(url, { headers: FETCH_HEADERS, redirect: 'follow' });
+ if (!res.ok) return undefined;
+ const contentType = (res.headers.get('content-type') || 'image/jpeg').split(';')[0].trim();
+ if (!contentType.startsWith('image/')) return undefined;
+
+ const reader = res.body?.getReader();
+ if (!reader) {
+ const buf = new Uint8Array(await res.arrayBuffer());
+ if (buf.byteLength > MAX_IMAGE_BYTES) return undefined;
+ return `data:${contentType};base64,${bytesToBase64(buf)}`;
+ }
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ total += value.byteLength;
+ if (total > MAX_IMAGE_BYTES) {
+ try {
+ await reader.cancel();
+ } catch {
+ /* ignore */
+ }
+ return undefined;
+ }
+ chunks.push(value);
+ }
+ const merged = new Uint8Array(total);
+ let off = 0;
+ for (const c of chunks) {
+ merged.set(c, off);
+ off += c.byteLength;
+ }
+ return `data:${contentType};base64,${bytesToBase64(merged)}`;
+ } catch (err) {
+ console.error('fetchImageAsDataUrl failed:', url, err);
+ return undefined;
+ }
+}
+
+function bytesToBase64(bytes: Uint8Array): string {
+ // btoa expects a binary string; build in chunks to avoid hitting argument
+ // limits with String.fromCharCode on multi-MB buffers.
+ let s = '';
+ const chunk = 0x8000;
+ for (let i = 0; i < bytes.length; i += chunk) {
+ s += String.fromCharCode.apply(
+ null,
+ Array.from(bytes.subarray(i, i + chunk)) as unknown as number[]
+ );
+ }
+ return btoa(s);
+}
diff --git a/apps/web/src/lib/import/ical.ts b/apps/web/src/lib/import/ical.ts
new file mode 100644
index 0000000..af54a6b
--- /dev/null
+++ b/apps/web/src/lib/import/ical.ts
@@ -0,0 +1,115 @@
+import type { EventImportPrefill } from '$lib/import-event';
+import type { EventImporter } from './types';
+import { offsetForZone } from './util';
+
+type IcalEvent = Omit;
+
+/**
+ * Importer for URLs that are themselves an iCalendar feed (`text/calendar`, or a
+ * body starting with `BEGIN:VCALENDAR`). HTML pages that merely *link* to an
+ * .ics are handled by the webpage importer, which reuses `parseIcal` below.
+ */
+export const icalImporter: EventImporter = {
+ name: 'ical',
+ async accept(ctx) {
+ const page = await ctx.getPage();
+ return (
+ page.contentType.includes('text/calendar') ||
+ /^BEGIN:VCALENDAR/m.test(page.text.slice(0, 200))
+ );
+ },
+ async parseData(ctx) {
+ const page = await ctx.getPage();
+ const ical = parseIcal(page.text);
+ return ical ? { source: page.finalUrl, ...ical } : null;
+ }
+};
+
+/** Parse the first VEVENT out of an iCalendar document. */
+export function parseIcal(text: string): IcalEvent | null {
+ const unfolded = text.replace(/\r?\n[ \t]/g, '');
+ const lines = unfolded.split(/\r?\n/);
+
+ let inEvent = false;
+ const fields: Record; value: string }> = {};
+
+ for (const raw of lines) {
+ if (raw === 'BEGIN:VEVENT') {
+ inEvent = true;
+ continue;
+ }
+ if (raw === 'END:VEVENT') break;
+ if (!inEvent) continue;
+
+ const colonIdx = raw.indexOf(':');
+ if (colonIdx < 0) continue;
+ const left = raw.slice(0, colonIdx);
+ const value = raw.slice(colonIdx + 1);
+ const parts = left.split(';');
+ const name = parts[0].toUpperCase();
+ const params: Record = {};
+ for (let i = 1; i < parts.length; i++) {
+ const eq = parts[i].indexOf('=');
+ if (eq > 0) {
+ params[parts[i].slice(0, eq).toUpperCase()] = parts[i].slice(eq + 1);
+ }
+ }
+ fields[name] = { params, value };
+ }
+
+ if (Object.keys(fields).length === 0) return null;
+
+ const out: IcalEvent = {};
+ const summary = fields.SUMMARY?.value;
+ if (summary) out.name = unescapeIcal(summary);
+ const description = fields.DESCRIPTION?.value;
+ if (description) out.description = unescapeIcal(description);
+ const url = fields.URL?.value;
+ if (url) out.links = [{ uri: url, name: 'Event page' }];
+
+ const start = fields.DTSTART;
+ if (start) {
+ const iso = icalDateToIso(start.value, start.params.TZID);
+ if (iso) out.startsAt = iso;
+ if (start.params.TZID) out.timezone = start.params.TZID;
+ }
+ const end = fields.DTEND;
+ if (end) {
+ const iso = icalDateToIso(end.value, end.params.TZID);
+ if (iso) out.endsAt = iso;
+ }
+
+ const location = fields.LOCATION?.value;
+ if (location) {
+ out.location = { street: unescapeIcal(location) };
+ }
+
+ return out;
+}
+
+function unescapeIcal(s: string): string {
+ return s.replace(/\\n/gi, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\');
+}
+
+function icalDateToIso(value: string, tzid?: string): string | undefined {
+ // All-day: YYYYMMDD
+ const dateOnly = value.match(/^(\d{4})(\d{2})(\d{2})$/);
+ if (dateOnly) {
+ return `${dateOnly[1]}-${dateOnly[2]}-${dateOnly[3]}T00:00:00`;
+ }
+ // UTC: YYYYMMDDTHHMMSSZ
+ const utc = value.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
+ if (utc) {
+ return `${utc[1]}-${utc[2]}-${utc[3]}T${utc[4]}:${utc[5]}:${utc[6]}Z`;
+ }
+ // Local: YYYYMMDDTHHMMSS (interpret in TZID if present)
+ const local = value.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/);
+ if (local) {
+ const base = `${local[1]}-${local[2]}-${local[3]}T${local[4]}:${local[5]}:${local[6]}`;
+ if (!tzid) return base;
+ // Compute offset for that wall time in tzid.
+ const offset = offsetForZone(tzid, base);
+ return offset ? `${base}${offset}` : base;
+ }
+ return undefined;
+}
diff --git a/apps/web/src/lib/import/index.ts b/apps/web/src/lib/import/index.ts
new file mode 100644
index 0000000..7d344f0
--- /dev/null
+++ b/apps/web/src/lib/import/index.ts
@@ -0,0 +1,50 @@
+import type { EventImportPrefill } from '$lib/import-event';
+import type { EventImporter, FetchedPage, ImportContext } from './types';
+import { FETCH_HEADERS, MAX_BYTES, readLimited } from './http';
+import { racoImporter } from './raco';
+import { icalImporter } from './ical';
+import { webpageImporter } from './webpage';
+
+/**
+ * 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];
+
+/**
+ * Run the import pipeline for a URL. The first importer whose `accept()` returns
+ * true owns the result — including `null` (nothing found) — so a known host that
+ * comes up empty doesn't fall through to a generic fetch it can't satisfy.
+ */
+export async function importFromUrl(url: string): Promise {
+ const ctx = createImportContext(url);
+ for (const importer of importers) {
+ if (await importer.accept(ctx)) {
+ return importer.parseData(ctx);
+ }
+ }
+ return null;
+}
+
+function createImportContext(url: string): ImportContext {
+ let pagePromise: Promise | null = null;
+ return {
+ url,
+ getPage() {
+ pagePromise ??= fetchPage(url);
+ return pagePromise;
+ }
+ };
+}
+
+async function fetchPage(url: string): Promise {
+ const res = await fetch(url, { headers: FETCH_HEADERS, redirect: 'follow' });
+ if (!res.ok) throw new Error(`upstream ${res.status}`);
+ const contentType = (res.headers.get('content-type') || '').toLowerCase();
+ const text = await readLimited(res, MAX_BYTES);
+ return { finalUrl: res.url || url, contentType, text };
+}
+
+export { fetchImageAsDataUrl } from './http';
+export type { EventImporter, ImportContext, FetchedPage } from './types';
diff --git a/apps/web/src/lib/import/jsonld.ts b/apps/web/src/lib/import/jsonld.ts
new file mode 100644
index 0000000..f6fcb8e
--- /dev/null
+++ b/apps/web/src/lib/import/jsonld.ts
@@ -0,0 +1,175 @@
+import type { EventImportPrefill } from '$lib/import-event';
+import { asString, normalizeIso, stripHtml } from './util';
+
+type JsonLdEvent = Record;
+
+/** Find the first schema.org *Event node embedded in a page's JSON-LD blocks. */
+export function extractJsonLdEvent(html: string): JsonLdEvent | null {
+ const re = /