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 = /]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; + for (const match of html.matchAll(re)) { + const raw = match[1].trim(); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + continue; + } + const event = findEventNode(parsed); + if (event) return event; + } + return null; +} + +function findEventNode(node: unknown): JsonLdEvent | null { + if (!node) return null; + if (Array.isArray(node)) { + for (const item of node) { + const found = findEventNode(item); + if (found) return found; + } + return null; + } + if (typeof node !== 'object') return null; + const obj = node as Record; + + const graph = obj['@graph']; + if (Array.isArray(graph)) { + const found = findEventNode(graph); + if (found) return found; + } + + const type = obj['@type']; + const types = Array.isArray(type) ? type : type ? [type] : []; + if (types.some((t) => typeof t === 'string' && /Event$/i.test(t))) { + return obj; + } + return null; +} + +export function mapJsonLdEvent( + ev: JsonLdEvent, + sourceUrl: string +): Omit { + const out: Omit = {}; + + const name = asString(ev.name); + if (name) out.name = name; + + const description = stripHtml(asString(ev.description)); + if (description) out.description = description; + + const startDate = asString(ev.startDate); + if (startDate) { + out.startsAt = normalizeIso(startDate); + const tz = tzFromIso(startDate); + if (tz) out.timezone = tz; + } + const endDate = asString(ev.endDate); + if (endDate) out.endsAt = normalizeIso(endDate); + + const mode = inferModeFromJsonLd(ev); + if (mode) out.mode = mode; + + const location = extractJsonLdLocation(ev.location); + if (location.address) out.location = location.address; + + const image = pickFirstImage(ev.image); + if (image) out.imageUrl = image; + + const links: Array<{ uri: string; name: string }> = []; + const eventUrl = asString(ev.url); + if (eventUrl && eventUrl !== sourceUrl) { + links.push({ uri: eventUrl, name: 'Event page' }); + } + if (location.virtualUrl) { + links.push({ uri: location.virtualUrl, name: 'Join link' }); + } + if (links.length > 0) out.links = links; + + return out; +} + +function tzFromIso(s: string): string | undefined { + // IANA tz isn't carried in ISO strings; only an offset is. Picking a zone + // from offset alone is ambiguous, so we defer guessing until we've assembled + // the whole prefill (see guessIanaZoneFromIsoOffset in the webpage importer). + void s; + return undefined; +} + +function inferModeFromJsonLd(ev: JsonLdEvent): EventImportPrefill['mode'] | undefined { + const raw = asString(ev.eventAttendanceMode); + if (!raw) return undefined; + const lower = raw.toLowerCase(); + if (lower.includes('mixed')) return 'hybrid'; + if (lower.includes('online')) return 'virtual'; + if (lower.includes('offline')) return 'inperson'; + return undefined; +} + +function extractJsonLdLocation(loc: unknown): { + address?: EventImportPrefill['location']; + virtualUrl?: string; +} { + if (!loc) return {}; + if (Array.isArray(loc)) { + const merged: ReturnType = {}; + for (const item of loc) { + const part = extractJsonLdLocation(item); + merged.address ??= part.address; + merged.virtualUrl ??= part.virtualUrl; + } + return merged; + } + if (typeof loc !== 'object') return {}; + const obj = loc as Record; + const type = asString(obj['@type']); + + if (type && /VirtualLocation/i.test(type)) { + return { virtualUrl: asString(obj.url) }; + } + + const address = obj.address; + if (typeof address === 'string') { + return { address: { street: address } }; + } + if (address && typeof address === 'object') { + const a = address as Record; + const out: NonNullable = {}; + const street = asString(a.streetAddress); + const locality = asString(a.addressLocality); + const region = asString(a.addressRegion); + const country = asString(a.addressCountry); + if (street) out.street = street; + if (locality) out.locality = locality; + if (region) out.region = region; + if (country) out.country = country; + const placeName = asString(obj.name); + if (placeName && !out.street) out.street = placeName; + else if (placeName && out.street && !out.street.includes(placeName)) { + out.street = `${placeName}, ${out.street}`; + } + return { address: Object.keys(out).length > 0 ? out : undefined }; + } + + const placeName = asString(obj.name); + if (placeName) return { address: { street: placeName } }; + return {}; +} + +function pickFirstImage(img: unknown): string | undefined { + if (!img) return undefined; + if (typeof img === 'string') return img; + if (Array.isArray(img)) { + for (const item of img) { + const v = pickFirstImage(item); + if (v) return v; + } + return undefined; + } + if (typeof img === 'object') { + const obj = img as Record; + return asString(obj.url) ?? asString(obj.contentUrl); + } + return undefined; +} diff --git a/apps/web/src/lib/import/raco.ts b/apps/web/src/lib/import/raco.ts new file mode 100644 index 0000000..2785715 --- /dev/null +++ b/apps/web/src/lib/import/raco.ts @@ -0,0 +1,166 @@ +import type { EventImportPrefill } from '$lib/import-event'; +import type { EventImporter } from './types'; +import { FETCH_HEADERS } from './http'; +import { offsetForZone } from './util'; + +/** + * Importer for Resident Advisor (ra.co). Its event *pages* sit behind DataDome + * bot protection, so fetching the HTML just yields a captcha stub (no + * JSON-LD/OG/ics). Its GraphQL API is open, though, and carries cleaner data — + * so this importer matches on the URL and talks to GraphQL directly, never + * touching ctx.getPage(). + */ +export const racoImporter: EventImporter = { + name: 'raco', + accept(ctx) { + return matchRaCoEvent(ctx.url) !== null; + }, + async parseData(ctx) { + const match = matchRaCoEvent(ctx.url); + if (!match) return null; + return importFromRaCo(match.id, match.canonicalUrl); + } +}; + +const RACO_GRAPHQL_URL = 'https://ra.co/graphql'; +const RACO_EVENT_QUERY = `query GET_EVENT_DETAIL($id: ID!) { + event(id: $id) { + id + title + content + startTime + endTime + flyerFront + flyerBack + venue { + name + address + area { + name + ianaTimeZone + country { name } + } + } + artists { name } + images { filename type } + } +}`; + +type RaCoEvent = { + id?: string; + title?: string; + content?: string; + startTime?: string; + endTime?: string; + flyerFront?: string | null; + flyerBack?: string | null; + venue?: { + name?: string; + address?: string; + area?: { + name?: string; + ianaTimeZone?: string; + country?: { name?: string } | null; + } | null; + } | null; + artists?: Array<{ name?: string } | null> | null; + images?: Array<{ filename?: string; type?: string } | null> | null; +}; + +function matchRaCoEvent(rawUrl: string): { id: string; canonicalUrl: string } | null { + let u: URL; + try { + u = new URL(rawUrl); + } catch { + return null; + } + if (u.hostname.toLowerCase().replace(/^www\./, '') !== 'ra.co') return null; + const m = u.pathname.match(/^\/events\/(\d+)/); + if (!m) return null; + return { id: m[1], canonicalUrl: `https://ra.co/events/${m[1]}` }; +} + +async function importFromRaCo(id: string, sourceUrl: string): Promise { + const res = await fetch(RACO_GRAPHQL_URL, { + method: 'POST', + headers: { + 'User-Agent': FETCH_HEADERS['User-Agent'], + 'Content-Type': 'application/json', + Accept: 'application/json', + // ra.co keys content language off this header; without it the API + // returns localized strings based on the (server) IP geo. + 'ra-content-language': 'en', + Referer: sourceUrl, + Origin: 'https://ra.co' + }, + body: JSON.stringify({ + operationName: 'GET_EVENT_DETAIL', + variables: { id }, + query: RACO_EVENT_QUERY + }) + }); + if (!res.ok) throw new Error(`ra.co graphql ${res.status}`); + const payload = (await res.json().catch(() => null)) as { + data?: { event?: RaCoEvent | null }; + } | null; + const ev = payload?.data?.event; + if (!ev || !ev.title) return null; + return mapRaCoEvent(ev, sourceUrl); +} + +function mapRaCoEvent(ev: RaCoEvent, sourceUrl: string): EventImportPrefill { + // ra.co events are physical club/festival dates. + const out: EventImportPrefill = { source: sourceUrl, mode: 'inperson' }; + + if (ev.title) out.name = ev.title; + + const tz = ev.venue?.area?.ianaTimeZone || undefined; + if (tz) out.timezone = tz; + if (ev.startTime) out.startsAt = raCoLocalToIso(ev.startTime, tz); + if (ev.endTime) out.endsAt = raCoLocalToIso(ev.endTime, tz); + + // Description is plain text; tack on the lineup since that's the heart of an + // RA listing and the API keeps it structured rather than in the body. + const parts: string[] = []; + const content = ev.content?.trim(); + if (content) parts.push(content); + const lineup = (ev.artists ?? []).map((a) => a?.name?.trim()).filter((n): n is string => !!n); + if (lineup.length) parts.push(`Lineup: ${lineup.join(', ')}`); + if (parts.length) out.description = parts.join('\n\n'); + + const venue = ev.venue; + if (venue) { + const loc: NonNullable = {}; + const name = venue.name?.trim(); + const address = venue.address?.trim(); + if (name && address) loc.street = `${name}, ${address}`; + else if (name) loc.street = name; + else if (address) loc.street = address; + const locality = venue.area?.name?.trim(); + if (locality) loc.locality = locality; + const country = venue.area?.country?.name?.trim(); + if (country) loc.country = country; + if (Object.keys(loc).length) out.location = loc; + } + + const image = pickRaCoImage(ev); + if (image) out.imageUrl = image; + + return out; +} + +function pickRaCoImage(ev: RaCoEvent): string | undefined { + const images = (ev.images ?? []).filter( + (i): i is { filename: string; type?: string } => !!i?.filename + ); + const front = images.find((i) => (i.type || '').toUpperCase() === 'FLYERFRONT'); + return front?.filename ?? images[0]?.filename ?? ev.flyerFront ?? ev.flyerBack ?? undefined; +} + +function raCoLocalToIso(value: string, tz?: string): string { + // ra.co serves naive venue wall-time, e.g. "2026-06-06T23:59:00.000". + const base = value.replace(/\.\d+$/, '').replace(/Z$/, ''); + if (!tz) return base; + const offset = offsetForZone(tz, base); + return offset ? `${base}${offset}` : base; +} diff --git a/apps/web/src/lib/import/types.ts b/apps/web/src/lib/import/types.ts new file mode 100644 index 0000000..859a813 --- /dev/null +++ b/apps/web/src/lib/import/types.ts @@ -0,0 +1,46 @@ +import type { EventImportPrefill } from '$lib/import-event'; + +/** A URL fetched once and shared across importers for a single import run. */ +export type FetchedPage = { + /** URL after any redirects. */ + finalUrl: string; + /** Lowercased `content-type` header (may be empty). */ + contentType: string; + /** Response body, truncated to MAX_BYTES. */ + text: string; +}; + +/** + * State passed to each importer for one import. `getPage()` lazily fetches the + * source URL and caches the result, so several content-based importers can + * inspect the same page without re-fetching. URL-based importers (e.g. ra.co) + * never call it and so never trigger a fetch they can't use. + */ +export type ImportContext = { + /** The URL the user pasted. */ + url: string; + /** + * Fetch the source URL (once). Rejects on a non-OK upstream so the caller + * surfaces a 502 rather than silently reporting "no event found". + */ + getPage(): Promise; +}; + +/** + * A source-specific importer. The registry tries each in order; the first whose + * `accept()` returns true owns the request and its `parseData()` result — even + * `null` — is final (we do not fall through to a later importer). Accept + * conditions are mutually exclusive in practice (a response is a calendar feed + * OR an HTML page OR a known host), which keeps that rule unambiguous. + */ +export type EventImporter = { + /** Stable identifier, for logs. */ + name: string; + /** + * Whether this importer handles the context. URL-based importers inspect + * `ctx.url`; content-based ones `await ctx.getPage()`. + */ + accept(ctx: ImportContext): boolean | Promise; + /** Parse the event, or return null when nothing usable is found. */ + parseData(ctx: ImportContext): Promise; +}; diff --git a/apps/web/src/lib/import/util.ts b/apps/web/src/lib/import/util.ts new file mode 100644 index 0000000..668870c --- /dev/null +++ b/apps/web/src/lib/import/util.ts @@ -0,0 +1,145 @@ +export function asString(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +export function stripHtml(s: string | undefined): string | undefined { + if (!s) return undefined; + return s + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export function normalizeIso(s: string): string { + // Date.parse handles most ISO variants; re-emit canonical form when we can. + const ms = Date.parse(s); + if (Number.isNaN(ms)) return s; + // Preserve the original offset when present (Date.parse → toISOString gives UTC). + if (/[+-]\d{2}:?\d{2}$|Z$/.test(s)) return s; + return new Date(ms).toISOString(); +} + +/** Format the UTC offset (e.g. "+02:00") of `tz` at the given naive wall time. */ +export function offsetForZone(tz: string, isoNoOffset: string): string | undefined { + try { + const ms = Date.parse(isoNoOffset + 'Z'); + if (Number.isNaN(ms)) return undefined; + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + timeZoneName: 'shortOffset', + hour: '2-digit' + }); + const parts = dtf.formatToParts(new Date(ms)); + const name = parts.find((p) => p.type === 'timeZoneName')?.value; + const m = name?.match(/GMT([+-]\d{1,2})(?::?(\d{2}))?/); + if (!m) return undefined; + const hours = parseInt(m[1], 10); + const mins = m[2] ? parseInt(m[2], 10) : 0; + const sign = hours >= 0 ? '+' : '-'; + const hh = String(Math.abs(hours)).padStart(2, '0'); + const mm = String(mins).padStart(2, '0'); + return `${sign}${hh}:${mm}`; + } catch { + return undefined; + } +} + +/** + * Curated list of representative IANA zones we'll consider when guessing from + * an ISO offset. Ordering matters — earlier entries win ties, so the most + * commonly-meant zone for each offset bucket should come first. + */ +const ZONE_CANDIDATES = [ + 'Pacific/Honolulu', + 'America/Anchorage', + 'America/Los_Angeles', + 'America/Denver', + 'America/Phoenix', + 'America/Chicago', + 'America/Mexico_City', + 'America/New_York', + 'America/Toronto', + 'America/Halifax', + 'America/Sao_Paulo', + 'America/Argentina/Buenos_Aires', + 'Atlantic/Azores', + 'UTC', + 'Europe/London', + 'Europe/Berlin', + 'Europe/Paris', + 'Europe/Madrid', + 'Africa/Cairo', + 'Europe/Athens', + 'Europe/Moscow', + 'Asia/Dubai', + 'Asia/Karachi', + 'Asia/Kolkata', + 'Asia/Dhaka', + 'Asia/Bangkok', + 'Asia/Singapore', + 'Asia/Shanghai', + 'Asia/Tokyo', + 'Australia/Sydney', + 'Pacific/Auckland' +]; + +export function guessIanaZoneFromIsoOffset(iso: string): string | undefined { + const m = iso.match(/([+-]\d{2}):?(\d{2})$|Z$/); + if (!m) return undefined; + const offsetMinutes = iso.endsWith('Z') + ? 0 + : (m[1].startsWith('-') ? -1 : 1) * (parseInt(m[1].slice(1), 10) * 60 + parseInt(m[2], 10)); + + // Offset 0 is almost always a platform serializing as UTC because it didn't + // track the authoring tz (Partiful does this). Returning "UTC" here would be + // wrong far more often than right — leave it unset so the editor falls back + // to the viewer's browser zone and they can correct it. + if (offsetMinutes === 0) return undefined; + + const ms = Date.parse(iso); + if (Number.isNaN(ms)) return undefined; + + for (const zone of ZONE_CANDIDATES) { + if (zoneOffsetAt(zone, ms) === offsetMinutes) return zone; + } + return undefined; +} + +function zoneOffsetAt(zone: string, ms: number): number | undefined { + try { + // Format the same instant as wall-clock parts in `zone`, reconstruct it + // as if it were UTC, and the difference is the zone's offset at `ms`. + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + const parts = Object.fromEntries( + dtf.formatToParts(new Date(ms)).map((p) => [p.type, p.value]) + ) as Record; + const asUtcMs = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour) === 24 ? 0 : Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + return Math.round((asUtcMs - ms) / 60000); + } catch { + return undefined; + } +} diff --git a/apps/web/src/lib/import/webpage.ts b/apps/web/src/lib/import/webpage.ts new file mode 100644 index 0000000..5ed910f --- /dev/null +++ b/apps/web/src/lib/import/webpage.ts @@ -0,0 +1,116 @@ +import type { EventImportPrefill } from '$lib/import-event'; +import type { EventImporter } from './types'; +import { FETCH_HEADERS, MAX_BYTES, readLimited } from './http'; +import { extractJsonLdEvent, mapJsonLdEvent } from './jsonld'; +import { parseIcal } from './ical'; +import { guessIanaZoneFromIsoOffset } from './util'; + +/** + * Fallback importer for ordinary HTML event pages (Luma, Meetup, Eventbrite, + * Partiful, …). Prefers JSON-LD, borrows a timezone from a linked .ics when + * JSON-LD only carries an offset, and finally falls back to OpenGraph tags. + */ +export const webpageImporter: EventImporter = { + name: 'webpage', + async accept(ctx) { + const page = await ctx.getPage(); + return page.contentType.includes('text/html') || / | null = null; + if (icalLink) { + try { + const icalRes = await fetch(icalLink, { headers: FETCH_HEADERS, redirect: 'follow' }); + if (icalRes.ok) { + const icalText = await readLimited(icalRes, MAX_BYTES); + ical = parseIcal(icalText); + } + } catch { + /* ignore — JSON-LD or OG will cover for it */ + } + } + + if (jsonLd) { + const mapped = mapJsonLdEvent(jsonLd, finalUrl); + if (!mapped.timezone && ical?.timezone) mapped.timezone = ical.timezone; + if (!mapped.timezone && mapped.startsAt) { + mapped.timezone = guessIanaZoneFromIsoOffset(mapped.startsAt); + } + return { source: finalUrl, ...mapped }; + } + + if (ical) return { source: finalUrl, ...ical }; + + const og = extractOpenGraph(text); + if (og.title || og.description) { + return { source: finalUrl, name: og.title, description: og.description, imageUrl: og.image }; + } + + return null; + } +}; + +function findAlternateIcalLink(html: string, baseUrl: string): string | null { + // Prefer the canonical tag. + const linkRe = /]+)>/gi; + for (const m of html.matchAll(linkRe)) { + const attrs = m[1]; + if (!/type=["']text\/calendar["']/i.test(attrs)) continue; + const href = attrs.match(/href=["']([^"']+)["']/i)?.[1]; + if (!href) continue; + try { + return new URL(href, baseUrl).toString(); + } catch { + continue; + } + } + + // Fall back to any anchor pointing at a .ics file or a calendar-export + // endpoint. Partiful, for instance, only exposes its calendar feed this way. + const anchorRe = /href=["']([^"']+\.ics(?:\?[^"']*)?)["']/gi; + for (const m of html.matchAll(anchorRe)) { + try { + return new URL(m[1], baseUrl).toString(); + } catch { + continue; + } + } + const calendarRe = + /href=["']([^"']*(?:add[-_]?to[-_]?calendar|\/calendar|\/ics|export\.ics)[^"']*)["']/gi; + for (const m of html.matchAll(calendarRe)) { + try { + return new URL(m[1], baseUrl).toString(); + } catch { + continue; + } + } + return null; +} + +function extractOpenGraph(html: string): { title?: string; description?: string; image?: string } { + const grab = (prop: string) => { + const re = new RegExp( + `]*?(?:property|name)=["']${prop}["'][^>]*?content=["']([^"']*)["']`, + 'i' + ); + const alt = new RegExp( + `]*?content=["']([^"']*)["'][^>]*?(?:property|name)=["']${prop}["']`, + 'i' + ); + return html.match(re)?.[1] ?? html.match(alt)?.[1]; + }; + return { + title: grab('og:title') ?? grab('twitter:title'), + description: grab('og:description') ?? grab('twitter:description') ?? grab('description'), + image: grab('og:image') ?? grab('twitter:image') + }; +} diff --git a/apps/web/src/routes/(app)/api/geocoding/+server.ts b/apps/web/src/routes/(app)/api/geocoding/+server.ts index 62c3a2b..a3c56c8 100644 --- a/apps/web/src/routes/(app)/api/geocoding/+server.ts +++ b/apps/web/src/routes/(app)/api/geocoding/+server.ts @@ -1,6 +1,10 @@ import { json } from '@sveltejs/kit'; -export async function GET({ url }) { +export async function GET({ url, locals }) { + if (!locals.did) { + return json({ error: 'You must be signed in.' }, { status: 401 }); + } + const q = url.searchParams.get('q'); if (!q) { return json({ error: 'No search provided' }, { status: 400 }); diff --git a/apps/web/src/routes/(app)/api/import-event/+server.ts b/apps/web/src/routes/(app)/api/import-event/+server.ts index a9c43bc..efb05f9 100644 --- a/apps/web/src/routes/(app)/api/import-event/+server.ts +++ b/apps/web/src/routes/(app)/api/import-event/+server.ts @@ -1,17 +1,11 @@ import { json } from '@sveltejs/kit'; -import type { EventImportPrefill } from '$lib/import-event'; +import { fetchImageAsDataUrl, importFromUrl } from '$lib/import'; -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' -}; - -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. -const MAX_IMAGE_BYTES = 3 * 1024 * 1024; +export async function POST({ request, locals }) { + if (!locals.did) { + return json({ error: 'You must be signed in to import events.' }, { status: 401 }); + } -export async function POST({ request }) { let body: { url?: string }; try { body = await request.json(); @@ -19,8 +13,12 @@ export async function POST({ request }) { return json({ error: 'Invalid JSON body' }, { status: 400 }); } - const sourceUrl = body.url?.trim(); - if (!sourceUrl) return json({ error: 'url is required' }, { status: 400 }); + const rawUrl = body.url?.trim(); + if (!rawUrl) return json({ error: 'url is required' }, { status: 400 }); + + // webcal:// is the de-facto scheme for calendar subscription links; rewrite it + // to https so a .ics feed can be pasted exactly as a calendar app hands it out. + const sourceUrl = rawUrl.replace(/^webcal:\/\//i, 'https://'); let parsedUrl: URL; try { @@ -35,10 +33,7 @@ export async function POST({ request }) { try { const result = await importFromUrl(sourceUrl); if (!result) { - return json( - { error: 'Could not find event data on that page.' }, - { status: 422 } - ); + return json({ error: 'Could not find event data on that page.' }, { status: 422 }); } if (result.imageUrl && !result.imageDataUrl) { const image = await fetchImageAsDataUrl(result.imageUrl); @@ -50,606 +45,3 @@ export async function POST({ request }) { return json({ error: 'Failed to fetch or parse that URL.' }, { status: 502 }); } } - -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); -} - -async function importFromUrl(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); - - const finalUrl = res.url || url; - - if (contentType.includes('text/calendar') || /^BEGIN:VCALENDAR/m.test(text.slice(0, 200))) { - const ical = parseIcal(text); - if (ical) return { source: finalUrl, ...ical }; - } - - if (contentType.includes('text/html') || / | null = null; - if (icalLink) { - try { - const icalRes = await fetch(icalLink, { headers: FETCH_HEADERS, redirect: 'follow' }); - if (icalRes.ok) { - const icalText = await readLimited(icalRes, MAX_BYTES); - ical = parseIcal(icalText); - } - } catch { - /* ignore — JSON-LD or OG will cover for it */ - } - } - - if (jsonLd) { - const mapped = mapJsonLdEvent(jsonLd, finalUrl); - if (!mapped.timezone && ical?.timezone) mapped.timezone = ical.timezone; - if (!mapped.timezone && mapped.startsAt) { - mapped.timezone = guessIanaZoneFromIsoOffset(mapped.startsAt); - } - return { source: finalUrl, ...mapped }; - } - - if (ical) return { source: finalUrl, ...ical }; - - const og = extractOpenGraph(text); - if (og.title || og.description) { - return { source: finalUrl, name: og.title, description: og.description, imageUrl: og.image }; - } - } - - return null; -} - -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; -} - -/* ---------------- JSON-LD ---------------- */ - -type JsonLdEvent = Record; - -function extractJsonLdEvent(html: string): JsonLdEvent | null { - const re = /]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; - for (const match of html.matchAll(re)) { - const raw = match[1].trim(); - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - continue; - } - const event = findEventNode(parsed); - if (event) return event; - } - return null; -} - -function findEventNode(node: unknown): JsonLdEvent | null { - if (!node) return null; - if (Array.isArray(node)) { - for (const item of node) { - const found = findEventNode(item); - if (found) return found; - } - return null; - } - if (typeof node !== 'object') return null; - const obj = node as Record; - - const graph = obj['@graph']; - if (Array.isArray(graph)) { - const found = findEventNode(graph); - if (found) return found; - } - - const type = obj['@type']; - const types = Array.isArray(type) ? type : type ? [type] : []; - if (types.some((t) => typeof t === 'string' && /Event$/i.test(t))) { - return obj; - } - return null; -} - -function mapJsonLdEvent(ev: JsonLdEvent, sourceUrl: string): Omit { - const out: Omit = {}; - - const name = asString(ev.name); - if (name) out.name = name; - - const description = stripHtml(asString(ev.description)); - if (description) out.description = description; - - const startDate = asString(ev.startDate); - if (startDate) { - out.startsAt = normalizeIso(startDate); - const tz = tzFromIso(startDate); - if (tz) out.timezone = tz; - } - const endDate = asString(ev.endDate); - if (endDate) out.endsAt = normalizeIso(endDate); - - const mode = inferModeFromJsonLd(ev); - if (mode) out.mode = mode; - - const location = extractJsonLdLocation(ev.location); - if (location.address) out.location = location.address; - - const image = pickFirstImage(ev.image); - if (image) out.imageUrl = image; - - const links: Array<{ uri: string; name: string }> = []; - const eventUrl = asString(ev.url); - if (eventUrl && eventUrl !== sourceUrl) { - links.push({ uri: eventUrl, name: 'Event page' }); - } - if (location.virtualUrl) { - links.push({ uri: location.virtualUrl, name: 'Join link' }); - } - if (links.length > 0) out.links = links; - - return out; -} - -function inferModeFromJsonLd(ev: JsonLdEvent): EventImportPrefill['mode'] | undefined { - const raw = asString(ev.eventAttendanceMode); - if (!raw) return undefined; - const lower = raw.toLowerCase(); - if (lower.includes('mixed')) return 'hybrid'; - if (lower.includes('online')) return 'virtual'; - if (lower.includes('offline')) return 'inperson'; - return undefined; -} - -function extractJsonLdLocation(loc: unknown): { - address?: EventImportPrefill['location']; - virtualUrl?: string; -} { - if (!loc) return {}; - if (Array.isArray(loc)) { - const merged: ReturnType = {}; - for (const item of loc) { - const part = extractJsonLdLocation(item); - merged.address ??= part.address; - merged.virtualUrl ??= part.virtualUrl; - } - return merged; - } - if (typeof loc !== 'object') return {}; - const obj = loc as Record; - const type = asString(obj['@type']); - - if (type && /VirtualLocation/i.test(type)) { - return { virtualUrl: asString(obj.url) }; - } - - const address = obj.address; - if (typeof address === 'string') { - return { address: { street: address } }; - } - if (address && typeof address === 'object') { - const a = address as Record; - const out: NonNullable = {}; - const street = asString(a.streetAddress); - const locality = asString(a.addressLocality); - const region = asString(a.addressRegion); - const country = asString(a.addressCountry); - if (street) out.street = street; - if (locality) out.locality = locality; - if (region) out.region = region; - if (country) out.country = country; - const placeName = asString(obj.name); - if (placeName && !out.street) out.street = placeName; - else if (placeName && out.street && !out.street.includes(placeName)) { - out.street = `${placeName}, ${out.street}`; - } - return { address: Object.keys(out).length > 0 ? out : undefined }; - } - - const placeName = asString(obj.name); - if (placeName) return { address: { street: placeName } }; - return {}; -} - -function pickFirstImage(img: unknown): string | undefined { - if (!img) return undefined; - if (typeof img === 'string') return img; - if (Array.isArray(img)) { - for (const item of img) { - const v = pickFirstImage(item); - if (v) return v; - } - return undefined; - } - if (typeof img === 'object') { - const obj = img as Record; - return asString(obj.url) ?? asString(obj.contentUrl); - } - return undefined; -} - -/* ---------------- iCal ---------------- */ - -type IcalEvent = Omit; - -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; -} - -function offsetForZone(tz: string, isoNoOffset: string): string | undefined { - try { - const ms = Date.parse(isoNoOffset + 'Z'); - if (Number.isNaN(ms)) return undefined; - const dtf = new Intl.DateTimeFormat('en-US', { - timeZone: tz, - timeZoneName: 'shortOffset', - hour: '2-digit' - }); - const parts = dtf.formatToParts(new Date(ms)); - const name = parts.find((p) => p.type === 'timeZoneName')?.value; - const m = name?.match(/GMT([+-]\d{1,2})(?::?(\d{2}))?/); - if (!m) return undefined; - const hours = parseInt(m[1], 10); - const mins = m[2] ? parseInt(m[2], 10) : 0; - const sign = hours >= 0 ? '+' : '-'; - const hh = String(Math.abs(hours)).padStart(2, '0'); - const mm = String(mins).padStart(2, '0'); - return `${sign}${hh}:${mm}`; - } catch { - return undefined; - } -} - -function findAlternateIcalLink(html: string, baseUrl: string): string | null { - // Prefer the canonical tag. - const linkRe = /]+)>/gi; - for (const m of html.matchAll(linkRe)) { - const attrs = m[1]; - if (!/type=["']text\/calendar["']/i.test(attrs)) continue; - const href = attrs.match(/href=["']([^"']+)["']/i)?.[1]; - if (!href) continue; - try { - return new URL(href, baseUrl).toString(); - } catch { - continue; - } - } - - // Fall back to any anchor pointing at a .ics file or a calendar-export - // endpoint. Partiful, for instance, only exposes its calendar feed this way. - const anchorRe = /href=["']([^"']+\.ics(?:\?[^"']*)?)["']/gi; - for (const m of html.matchAll(anchorRe)) { - try { - return new URL(m[1], baseUrl).toString(); - } catch { - continue; - } - } - const calendarRe = /href=["']([^"']*(?:add[-_]?to[-_]?calendar|\/calendar|\/ics|export\.ics)[^"']*)["']/gi; - for (const m of html.matchAll(calendarRe)) { - try { - return new URL(m[1], baseUrl).toString(); - } catch { - continue; - } - } - return null; -} - -/* ---------------- OpenGraph ---------------- */ - -function extractOpenGraph(html: string): { title?: string; description?: string; image?: string } { - const grab = (prop: string) => { - const re = new RegExp( - `]*?(?:property|name)=["']${prop}["'][^>]*?content=["']([^"']*)["']`, - 'i' - ); - const alt = new RegExp( - `]*?content=["']([^"']*)["'][^>]*?(?:property|name)=["']${prop}["']`, - 'i' - ); - return html.match(re)?.[1] ?? html.match(alt)?.[1]; - }; - return { - title: grab('og:title') ?? grab('twitter:title'), - description: grab('og:description') ?? grab('twitter:description') ?? grab('description'), - image: grab('og:image') ?? grab('twitter:image') - }; -} - -/* ---------------- helpers ---------------- */ - -function asString(v: unknown): string | undefined { - return typeof v === 'string' && v.length > 0 ? v : undefined; -} - -function stripHtml(s: string | undefined): string | undefined { - if (!s) return undefined; - return s - .replace(//gi, '\n') - .replace(/<\/p>/gi, '\n\n') - .replace(/<[^>]+>/g, '') - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/\n{3,}/g, '\n\n') - .trim(); -} - -function normalizeIso(s: string): string { - // Date.parse handles most ISO variants; re-emit canonical form when we can. - const ms = Date.parse(s); - if (Number.isNaN(ms)) return s; - // Preserve the original offset when present (Date.parse → toISOString gives UTC). - if (/[+-]\d{2}:?\d{2}$|Z$/.test(s)) return s; - return new Date(ms).toISOString(); -} - -function tzFromIso(s: string): string | undefined { - // IANA tz isn't carried in ISO strings; only an offset is. Picking a zone - // from offset alone is ambiguous, so we defer guessing until we've assembled - // the whole prefill (see guessIanaZoneFromIsoOffset). - void s; - return undefined; -} - -/** - * Curated list of representative IANA zones we'll consider when guessing from - * an ISO offset. Ordering matters — earlier entries win ties, so the most - * commonly-meant zone for each offset bucket should come first. - */ -const ZONE_CANDIDATES = [ - 'Pacific/Honolulu', - 'America/Anchorage', - 'America/Los_Angeles', - 'America/Denver', - 'America/Phoenix', - 'America/Chicago', - 'America/Mexico_City', - 'America/New_York', - 'America/Toronto', - 'America/Halifax', - 'America/Sao_Paulo', - 'America/Argentina/Buenos_Aires', - 'Atlantic/Azores', - 'UTC', - 'Europe/London', - 'Europe/Berlin', - 'Europe/Paris', - 'Europe/Madrid', - 'Africa/Cairo', - 'Europe/Athens', - 'Europe/Moscow', - 'Asia/Dubai', - 'Asia/Karachi', - 'Asia/Kolkata', - 'Asia/Dhaka', - 'Asia/Bangkok', - 'Asia/Singapore', - 'Asia/Shanghai', - 'Asia/Tokyo', - 'Australia/Sydney', - 'Pacific/Auckland' -]; - -function guessIanaZoneFromIsoOffset(iso: string): string | undefined { - const m = iso.match(/([+-]\d{2}):?(\d{2})$|Z$/); - if (!m) return undefined; - const offsetMinutes = iso.endsWith('Z') - ? 0 - : (m[1].startsWith('-') ? -1 : 1) * (parseInt(m[1].slice(1), 10) * 60 + parseInt(m[2], 10)); - - // Offset 0 is almost always a platform serializing as UTC because it didn't - // track the authoring tz (Partiful does this). Returning "UTC" here would be - // wrong far more often than right — leave it unset so the editor falls back - // to the viewer's browser zone and they can correct it. - if (offsetMinutes === 0) return undefined; - - const ms = Date.parse(iso); - if (Number.isNaN(ms)) return undefined; - - for (const zone of ZONE_CANDIDATES) { - if (zoneOffsetAt(zone, ms) === offsetMinutes) return zone; - } - return undefined; -} - -function zoneOffsetAt(zone: string, ms: number): number | undefined { - try { - // Format the same instant as wall-clock parts in `zone`, reconstruct it - // as if it were UTC, and the difference is the zone's offset at `ms`. - const dtf = new Intl.DateTimeFormat('en-US', { - timeZone: zone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false - }); - const parts = Object.fromEntries( - dtf.formatToParts(new Date(ms)).map((p) => [p.type, p.value]) - ) as Record; - const asUtcMs = Date.UTC( - Number(parts.year), - Number(parts.month) - 1, - Number(parts.day), - Number(parts.hour) === 24 ? 0 : Number(parts.hour), - Number(parts.minute), - Number(parts.second) - ); - return Math.round((asUtcMs - ms) / 60000); - } catch { - return undefined; - } -}