#!/usr/bin/env node /** * ics-interview.mjs — read an interview invite's .ics into an * active-interviews.md row (#5743). * * Interview invites very often carry an .ics attachment, and that file is the * one place holding the exact start, end, time zone and link in machine-readable * form. invite-match.mjs reads the invite TEXT; nothing read the .ics itself, * so the candidate copied the date, zone, link and length by hand. * * For the FIRST VEVENT this prints (JSON, or --summary): * - start / end as stated (original TZID kept) and the same instant in the * display zone, plus durationMinutes (from DTEND or DURATION) * - summary, location, a meeting URL found in LOCATION / DESCRIPTION / URL * - organizer and attendees (display names only, never addresses) * - a ready-to-paste `| Company | Role | Round | Date/Time | Interviewer | * Status | Notes |` row for data/active-interviews.md, with every field * that could not be read left as an em dash rather than guessed * * Read-only and zero-LLM: it never writes data/active-interviews.md (the user * pastes the row), and the iCalendar subset it needs is parsed by hand, so * there is no new dependency. * * Time semantics, reported rather than assumed: * - `...Z` UTC instant * - `;TZID=Area/City` wall time in that zone, converted to an instant * - no zone and no `Z` floating: a wall time with NO zone; not converted * - `;VALUE=DATE` all-day: reported as a date, never as a time * - a TZID that is not an IANA zone (e.g. Outlook's "Eastern Standard Time") * is converted with the file's own VTIMEZONE block; without a readable * one it is reported as `unknown-tzid` and not converted * - RRULE is ignored (only the first occurrence is read; a warning says so) * - a cancellation (METHOD:CANCEL or STATUS:CANCELLED) gets no row * * Display zone: --tz, else config/profile.yml `location.timezone` (when it is * a valid IANA name), else this machine's zone. * * Usage: * node ics-interview.mjs invite.ics # JSON * node ics-interview.mjs invite.ics --summary # human-readable * node ics-interview.mjs < invite.ics # read stdin * node ics-interview.mjs invite.ics --tz America/Toronto * node ics-interview.mjs invite.ics --match "" */ import { readFileSync, existsSync } from 'fs'; import { loadYaml } from './lib/yaml-document.mjs'; import { getProfilePath } from './path-resolver.mjs'; import { flagValue, validateFlags } from './lib/cli-flags.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; const DASH = '—'; // --------------------------------------------------------------------------- // iCalendar parsing // --------------------------------------------------------------------------- /** RFC 5545 line unfolding: a CRLF (or LF) followed by one space/tab continues the line. */ export function unfoldLines(text) { const raw = String(text ?? '').replace(/^\uFEFF/, '').split(/\r\n|\n|\r/); const out = []; for (const line of raw) { if ((line.startsWith(' ') || line.startsWith('\t')) && out.length > 0) { out[out.length - 1] += line.slice(1); } else { out.push(line); } } return out.filter((l) => l.length > 0); } /** * Split one unfolded content line into { name, params, value }. Parameter * values may be double-quoted and then may contain ':' / ';' / ','. */ export function parseContentLine(line) { let i = 0; const n = line.length; const readUntil = (stops) => { let s = ''; let quoted = false; while (i < n) { const c = line[i]; if (c === '"') { quoted = !quoted; s += c; i += 1; continue; } if (!quoted && stops.includes(c)) break; s += c; i += 1; } return s; }; const name = readUntil([':', ';']).toUpperCase(); const params = {}; while (i < n && line[i] === ';') { i += 1; const key = readUntil(['=', ':', ';']).toUpperCase(); let val = ''; if (line[i] === '=') { i += 1; val = readUntil([':', ';']); } params[key] = val.replace(/^"(.*)"$/, '$1'); } if (line[i] !== ':') return null; return { name, params, value: line.slice(i + 1) }; } /** iCalendar TEXT unescaping: \n \N newline, \, \; \\ literals. */ export function unescapeText(value) { return String(value ?? '').replace(/\\([nN,;\\])/g, (_, c) => (c === 'n' || c === 'N' ? '\n' : c)); } /** Parse an iCalendar DURATION (PT45M, PT1H30M, P1D, P1W, -PT15M) into minutes, or null. */ export function parseDurationMinutes(value) { const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/i.exec(String(value ?? '').trim()); if (!m || value.trim().length <= 1) return null; const [, sign, w, d, h, mi, s] = m; const total = (Number(w || 0) * 7 + Number(d || 0)) * 1440 + Number(h || 0) * 60 + Number(mi || 0) + Number(s || 0) / 60; return (sign === '-' ? -1 : 1) * Math.round(total); } /** * Parse a DTSTART / DTEND value. Returns null when the value is unreadable. * kind: 'date' (all-day) | 'utc' | 'zoned' | 'floating' */ export function parseIcsDate(value, params = {}) { const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z)?)?$/i.exec(String(value ?? '').trim()); if (!m) return null; const [, y, mo, d, h, mi, s, z] = m; // Date.UTC remaps years 0-99 to 1900-1999, and an interview in those years is // not a real invite, so a year before 1000 is treated as unreadable. if (+y < 1000) return null; const base = { y: +y, mo: +mo, d: +d, h: +(h ?? 0), mi: +(mi ?? 0), s: +(s ?? 0), raw: value.trim() }; if (mo === '00' || +mo > 12 || +d < 1 || base.h > 23 || base.mi > 59 || base.s > 60) return null; // The day must exist in that month (Feb 30 would otherwise roll into March). if (+d > new Date(Date.UTC(+y, +mo, 0)).getUTCDate()) return null; const isDateOnly = h === undefined || String(params.VALUE ?? '').toUpperCase() === 'DATE'; if (isDateOnly) return { ...base, kind: 'date', tzid: null }; if (z) return { ...base, kind: 'utc', tzid: null }; if (params.TZID) return { ...base, kind: 'zoned', tzid: params.TZID }; return { ...base, kind: 'floating', tzid: null }; } /** * Parse the FIRST VEVENT of an .ics body, plus the calendar's METHOD. * @returns {{ props: Array<{name:string, params:object, value:string}>, method: string|null } | null} */ export function firstEvent(text) { const lines = unfoldLines(text); let depth = 0; let inEvent = false; let method = null; const props = []; for (const line of lines) { const p = parseContentLine(line); if (!p) continue; if (p.name === 'BEGIN') { if (!inEvent && p.value.toUpperCase() === 'VCALENDAR') { method = null; continue; } if (!inEvent && p.value.toUpperCase() === 'VEVENT') { inEvent = true; depth = 0; continue; } if (inEvent) depth += 1; continue; } if (p.name === 'END') { if (inEvent && depth === 0 && p.value.toUpperCase() === 'VEVENT') return { props, method }; if (inEvent) depth -= 1; continue; } if (!inEvent && p.name === 'METHOD') method = p.value.trim().toUpperCase(); // Properties of nested components (e.g. VALARM) are not the event's. if (inEvent && depth === 0) props.push(p); } // A truncated file that never closes the VEVENT still yields what it had. return inEvent ? { props, method } : null; } // --------------------------------------------------------------------------- // Time zones (Intl, else the file's own VTIMEZONE) // --------------------------------------------------------------------------- const WEEKDAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; function parseUtcOffset(value) { const m = /^([+-])(\d{2})(\d{2})(\d{2})?$/.exec(String(value ?? '').trim()); if (!m) return null; // Hours stop at 23, minutes at 59, and seconds at 60. A minute of 60 would // still convert and hide a broken zone. const hour = Number(m[2]); const minute = Number(m[3]); const second = Number(m[4] ?? 0); if (hour > 23 || minute > 59 || second > 60) return null; const sign = m[1] === '-' ? -1 : 1; return sign * (hour * 60 + minute + second / 60); } // Seconds make the minute count fractional. Round so the instant lands on a whole millisecond. function offsetMinutesToMs(minutes) { return Math.round(minutes * 60000); } /** * One STANDARD or DAYLIGHT block. Only the yearly BYMONTH + BYDAY rule that * Outlook, Exchange and most generators write is read; anything else is null. */ function parseObservance(props) { const get = (name) => props.find((p) => p.name === name)?.value; const start = parseIcsDate(get('DTSTART')); const from = parseUtcOffset(get('TZOFFSETFROM')); const to = parseUtcOffset(get('TZOFFSETTO')); if (!start || start.kind !== 'floating' || from === null || to === null) return null; // RDATE lists transitions this parser does not read. Treating the block as // one onset at DTSTART would apply the wrong offset and still look converted. if (props.some((p) => p.name === 'RDATE')) return null; const rrule = get('RRULE'); if (!rrule) return { start, from, to, rule: null }; const parts = Object.fromEntries(rrule.split(';').map((kv) => kv.split('=').map((x) => x.trim().toUpperCase()))); const allowed = new Set(['FREQ', 'INTERVAL', 'BYMONTH', 'BYDAY', 'UNTIL', 'WKST']); if (parts.FREQ !== 'YEARLY' || (parts.INTERVAL ?? '1') !== '1' || Object.keys(parts).some((k) => !allowed.has(k))) return null; const day = /^([+-]?[1-5])(SU|MO|TU|WE|TH|FR|SA)$/.exec(parts.BYDAY ?? ''); const month = Number(parts.BYMONTH); if (!day || !Number.isInteger(month) || month < 1 || month > 12) return null; const until = parts.UNTIL ? parseIcsDate(parts.UNTIL) : null; if (parts.UNTIL && !until) return null; return { start, from, to, rule: { month, nth: Number(day[1]), weekday: WEEKDAYS.indexOf(day[2]), until } }; } /** Every VTIMEZONE in the body, keyed by TZID. A zone with any unreadable block is left out. */ export function parseVtimezones(text) { const zones = new Map(); let tzid = null; let blocks = null; let block = null; for (const line of unfoldLines(text)) { const p = parseContentLine(line); if (!p) continue; const value = p.value.trim().toUpperCase(); if (p.name === 'BEGIN' && value === 'VTIMEZONE') { tzid = null; blocks = []; continue; } if (!blocks) continue; if (p.name === 'BEGIN' && (value === 'STANDARD' || value === 'DAYLIGHT')) { block = []; continue; } if (p.name === 'END' && block && (value === 'STANDARD' || value === 'DAYLIGHT')) { blocks.push(parseObservance(block)); block = null; continue; } if (p.name === 'END' && value === 'VTIMEZONE') { if (tzid && blocks.length > 0 && blocks.every(Boolean)) zones.set(tzid, blocks); blocks = null; continue; } if (block) block.push(p); else if (p.name === 'TZID') tzid = p.value.trim(); } return zones; } /** Wall-clock ms (the wall time read as if it were UTC) when an observance starts in `year`, or null. */ function onsetIn(obs, year) { const { start, rule } = obs; const at = (y, mo, d) => Date.UTC(y, mo - 1, d, start.h, start.mi, start.s); const first = at(start.y, start.mo, start.d); if (!rule) return first; let day; if (rule.nth > 0) { const firstWeekday = new Date(Date.UTC(year, rule.month - 1, 1)).getUTCDay(); day = 1 + ((rule.weekday - firstWeekday + 7) % 7) + (rule.nth - 1) * 7; } else { const last = new Date(Date.UTC(year, rule.month, 0)).getUTCDate(); const lastWeekday = new Date(Date.UTC(year, rule.month - 1, last)).getUTCDay(); day = last - ((lastWeekday - rule.weekday + 7) % 7) + (rule.nth + 1) * 7; } if (day < 1 || day > new Date(Date.UTC(year, rule.month, 0)).getUTCDate()) return null; const onset = at(year, rule.month, day); if (onset < first) return null; if (rule.until) { const u = rule.until; const untilMs = Date.UTC(u.y, u.mo - 1, u.d, u.h, u.mi, u.s) + (u.kind === 'utc' ? offsetMinutesToMs(obs.from) : 0); if (onset > untilMs) return null; } return onset; } /** * Wall-clock time -> epoch ms through a VTIMEZONE, with the same overlap and * gap rules as wallToInstant. Null when no observance covers the time. */ export function vtimezoneToInstant(d, observances) { const wallMs = Date.UTC(d.y, d.mo - 1, d.d, d.h, d.mi, d.s); let best = null; for (const obs of observances) { // A yearly rule can fire its last onset many years before the event. // Walk back to the year it started. The first onset that already applies // is the latest one, so an earlier year cannot replace it. const firstYear = obs.rule ? obs.start.y : d.y; for (let year = d.y; year >= firstYear; year -= 1) { const onset = onsetIn(obs, year); if (onset === null || onset > wallMs) continue; if (!best || onset > best.onset) best = { onset, obs }; break; } } if (!best) return null; const { onset, obs } = best; // Inside a spring-forward gap the pre-gap offset applies (RFC 5545 3.3.5). const fromMs = offsetMinutesToMs(obs.from); const toMs = offsetMinutesToMs(obs.to); const appliedMs = wallMs < onset + (toMs - fromMs) ? fromMs : toMs; return wallMs - appliedMs; } export function isValidZone(zone) { if (typeof zone !== 'string' || !zone.trim()) return false; try { new Intl.DateTimeFormat('en-US', { timeZone: zone }); return true; } catch { return false; } } export function machineZone() { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } function zoneParts(ms, zone) { const fmt = new Intl.DateTimeFormat('en-US', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'short', }); const o = {}; for (const p of fmt.formatToParts(new Date(ms))) o[p.type] = p.value; return o; } function offsetMs(ms, zone) { const o = zoneParts(ms, zone); const asUtc = Date.UTC(+o.year, +o.month - 1, +o.day, +o.hour % 24, +o.minute, +o.second); return asUtc - Math.floor(ms / 1000) * 1000; } /** Wall-clock time -> epoch ms, using RFC 5545's overlap and gap rules. */ export function wallToInstant(d, zone) { const guess = Date.UTC(d.y, d.mo - 1, d.d, d.h, d.mi, d.s); // Sample both sides of a transition instead of letting the UTC guess choose // an arbitrary side. Offsets need not differ by a whole hour. const offsets = new Set([-86400000, 0, 86400000].map(delta => offsetMs(guess + delta, zone))); const candidates = [...offsets].map(offset => guess - offset); const matches = candidates.filter(ms => ms + offsetMs(ms, zone) === guess); // An overlap means two matches: use the first occurrence. A gap has none: // applying the pre-gap offset gives the later instant (RFC 5545 3.3.5). return matches.length ? Math.min(...matches) : Math.max(...candidates); } /** Instant formatted in a zone: { date: 'YYYY-MM-DD', time: 'HH:MM', abbr, zone }. */ export function formatInZone(ms, zone) { const o = zoneParts(ms, zone); return { date: `${o.year}-${o.month}-${o.day}`, time: `${o.hour}:${o.minute}`, abbr: o.timeZoneName, zone }; } const pad = (n) => String(n).padStart(2, '0'); const wallString = (d) => `${d.y}-${pad(d.mo)}-${pad(d.d)}T${pad(d.h)}:${pad(d.mi)}:${pad(d.s)}`; /** * Describe one parsed DTSTART/DTEND: the time as stated, plus its instant when * one can be established without assuming anything. */ function describeTime(t, warnings, label, vtimezones = new Map()) { if (!t) return null; const out = { raw: t.raw, kind: t.kind, tzid: t.tzid, local: t.kind === 'date' ? `${t.y}-${pad(t.mo)}-${pad(t.d)}` : wallString(t), utc: null, epochMs: null }; if (t.kind === 'utc') { out.epochMs = Date.UTC(t.y, t.mo - 1, t.d, t.h, t.mi, t.s); } else if (t.kind === 'zoned') { const viaVtimezone = isValidZone(t.tzid) ? null : vtimezones.get(t.tzid); const fromVtimezone = viaVtimezone ? vtimezoneToInstant(t, viaVtimezone) : null; if (isValidZone(t.tzid)) { out.epochMs = wallToInstant(t, t.tzid); } else if (fromVtimezone !== null) { out.epochMs = fromVtimezone; } else { out.kind = 'unknown-tzid'; warnings.push({ code: 'unknown-tzid', message: `${label} TZID "${t.tzid}" is not an IANA time zone this tool can read; the time is shown as written and not converted.` }); } } else if (t.kind === 'floating') { warnings.push({ code: 'floating-time', message: `${label} has no time zone (floating); it is shown as written and not converted.` }); } if (out.epochMs !== null) out.utc = new Date(out.epochMs).toISOString().replace('.000Z', 'Z'); return out; } // --------------------------------------------------------------------------- // Event extraction // --------------------------------------------------------------------------- const URL_RE = /https?:\/\/[^\s<>"'\\)\]]+/gi; const MEETING_HOST_RE = /(zoom\.us|zoomgov\.com|teams\.microsoft\.com|teams\.live\.com|meet\.google\.com|webex\.com|gotomeeting\.com|goto\.com|whereby\.com|bluejeans\.com|chime\.aws|hirevue\.com|calendly\.com)/i; /** First meeting-looking URL across the given texts, else the first URL at all, else null. */ export function findMeetingUrl(...texts) { const urls = []; for (const t of texts) { for (const m of String(t ?? '').matchAll(URL_RE)) urls.push(m[0].replace(/[.,;:!?]+$/, '')); } return urls.find((u) => MEETING_HOST_RE.test(u)) ?? urls[0] ?? null; } function personName(prop) { const cn = prop?.params?.CN; const name = cn ? unescapeText(cn).trim() : ''; // Names only: a CN that is itself an address (or a mailto:) is never emitted. if (!name || /^mailto:/i.test(name) || /\S+@\S+/.test(name)) return null; return name; } /** * Parse an .ics body. * @param {string} text * @param {{displayZone?: string}} [opts] * @returns {{event: object|null, warnings: Array<{code:string,message:string}>}} */ export function parseIcs(text, { displayZone } = {}) { const warnings = []; const ev = firstEvent(text); if (!ev) { warnings.push({ code: 'no-vevent', message: 'No VEVENT found in the input.' }); return { event: null, warnings }; } const get = (name) => ev.props.find((p) => p.name === name); const text_ = (name) => { const p = get(name); return p ? unescapeText(p.value).trim() || null : null; }; // A cancellation carries the same start and end as the invite it cancels. const cancelled = ev.method === 'CANCEL' || text_('STATUS')?.toUpperCase() === 'CANCELLED'; if (cancelled) warnings.push({ code: 'cancelled', message: 'This .ics cancels the event. There is no row to paste; update the existing row in data/active-interviews.md instead.' }); if (get('RRULE')) warnings.push({ code: 'rrule-ignored', message: 'RRULE present and ignored; only the first occurrence is read.' }); const vtimezones = parseVtimezones(text); const dtstartProp = get('DTSTART'); const start = describeTime(dtstartProp ? parseIcsDate(dtstartProp.value, dtstartProp.params) : null, warnings, 'DTSTART', vtimezones); if (!start) warnings.push({ code: 'no-start', message: dtstartProp ? `DTSTART "${dtstartProp.value}" is not readable.` : 'No DTSTART.' }); const dtendProp = get('DTEND'); const durProp = get('DURATION'); let end = describeTime(dtendProp ? parseIcsDate(dtendProp.value, dtendProp.params) : null, warnings, 'DTEND', vtimezones); if (dtendProp && !end) warnings.push({ code: 'bad-end', message: `DTEND "${dtendProp.value}" is not readable.` }); let durationMinutes = null; if (start && end) { if (start.kind === 'date' || end.kind === 'date') { if (start.kind === 'date' && end.kind === 'date') { durationMinutes = Math.round((Date.UTC(+end.local.slice(0, 4), +end.local.slice(5, 7) - 1, +end.local.slice(8, 10)) - Date.UTC(+start.local.slice(0, 4), +start.local.slice(5, 7) - 1, +start.local.slice(8, 10))) / 60000); } } else if (start.epochMs !== null && end.epochMs !== null) { durationMinutes = Math.round((end.epochMs - start.epochMs) / 60000); } else if (start.kind === end.kind && start.tzid === end.tzid && start.kind !== 'zoned') { // Same floating clock on both ends: the difference is meaningful without a zone. durationMinutes = Math.round((Date.parse(`${end.local}Z`) - Date.parse(`${start.local}Z`)) / 60000); } else if (start.tzid && start.tzid === end.tzid) { // Same unreadable TZID on both ends: wall-clock difference. durationMinutes = Math.round((Date.parse(`${end.local}Z`) - Date.parse(`${start.local}Z`)) / 60000); } } else if (start && !end && durProp) { durationMinutes = parseDurationMinutes(durProp.value); if (durationMinutes === null) warnings.push({ code: 'bad-duration', message: `DURATION "${durProp.value}" is not readable.` }); else if (start.kind !== 'date' && start.epochMs !== null) { const epochMs = start.epochMs + durationMinutes * 60000; end = { raw: null, kind: 'derived', tzid: start.tzid, local: null, utc: new Date(epochMs).toISOString().replace('.000Z', 'Z'), epochMs }; } } else if (start && !end) { warnings.push({ code: 'no-end', message: 'No DTEND or DURATION; the length is not stated in the .ics.' }); } if (durationMinutes !== null && durationMinutes < 0) { warnings.push({ code: 'negative-duration', message: 'End is before start; length left unstated.' }); durationMinutes = null; } const allDay = start?.kind === 'date'; const zone = displayZone ?? machineZone(); let display = null; if (start && start.epochMs !== null && !allDay) { display = { zone, start: formatInZone(start.epochMs, zone), end: end && end.epochMs !== null ? formatInZone(end.epochMs, zone) : null }; } const organizerProp = get('ORGANIZER'); const attendees = ev.props.filter((p) => p.name === 'ATTENDEE').map(personName).filter(Boolean); const organizer = personName(organizerProp); const strip = (t) => (t ? { raw: t.raw, kind: t.kind, tzid: t.tzid, local: t.local, utc: t.utc } : null); return { event: { summary: text_('SUMMARY'), location: text_('LOCATION'), meetingUrl: findMeetingUrl(text_('LOCATION'), text_('DESCRIPTION'), text_('URL')), organizer, attendees, allDay, cancelled, start: strip(start), end: strip(end), durationMinutes: allDay ? null : durationMinutes, display, }, warnings, }; } // --------------------------------------------------------------------------- // active-interviews.md row // --------------------------------------------------------------------------- const cell = (v) => { const s = String(v ?? '').replace(/\s+/g, ' ').replace(/\|/g, '/').trim(); return s || DASH; }; /** Date/Time cell text for the row (never invents a time the .ics did not carry). */ export function dateTimeCell(event) { if (!event?.start) return DASH; const s = event.start; if (event.allDay) return `${s.local} (all-day, no time stated)`; if (s.kind === 'floating') return `${s.local.slice(0, 10)} ${s.local.slice(11, 16)} (no time zone)`; if (s.kind === 'unknown-tzid') return `${s.local.slice(0, 10)} ${s.local.slice(11, 16)} (unknown TZID ${s.tzid})`; const d = event.display; if (!d) return DASH; let out = `${d.start.date} ${d.start.time}`; if (d.end) out += d.end.date === d.start.date ? `-${d.end.time}` : ` - ${d.end.date} ${d.end.time}`; return `${out} ${d.start.abbr}`; } /** * Build the pasteable row. `tracker` (optional) is a matched tracker candidate * supplying Company and Role; otherwise those stay em dashes. */ export function buildRow(event, tracker = null) { if (!event || event.cancelled) return null; const notes = []; if (event.durationMinutes !== null && event.durationMinutes !== undefined) notes.push(`${event.durationMinutes} min`); if (event.start?.tzid && event.start.kind !== 'unknown-tzid') notes.push(`invite zone ${event.start.tzid}`); if (event.meetingUrl) notes.push(event.meetingUrl); const cells = [ cell(tracker?.company), cell(tracker?.role), DASH, cell(dateTimeCell(event)), cell(event.organizer), 'Scheduled', cell(notes.join('; ')), ]; return `| ${cells.join(' | ')} |`; } // --------------------------------------------------------------------------- // Display zone // --------------------------------------------------------------------------- /** --tz > profile location.timezone (valid IANA only) > machine zone. */ export function resolveDisplayZone(tzFlag, warnings = []) { if (tzFlag !== undefined) { if (isValidZone(tzFlag)) return { zone: tzFlag, source: '--tz' }; warnings.push({ code: 'display-zone-invalid', message: `--tz "${tzFlag}" is not an IANA time zone; using the next fallback.` }); } try { const path = getProfilePath(); if (path && existsSync(path)) { const tz = loadYaml(readFileSync(path, 'utf-8'))?.location?.timezone; if (typeof tz === 'string' && tz.trim()) { if (isValidZone(tz.trim())) return { zone: tz.trim(), source: 'profile' }; warnings.push({ code: 'display-zone-invalid', message: `config/profile.yml location.timezone "${tz}" is not a time zone name Intl knows (e.g. America/Toronto); using the machine zone.` }); } } } catch (e) { warnings.push({ code: 'profile-unreadable', message: `config/profile.yml could not be read (${e.message}); using the machine zone.` }); } return { zone: machineZone(), source: 'machine' }; } // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- const KNOWN_FLAGS = ['--summary', '--tz', '--match', '--help', '-h']; const VALUE_FLAGS = ['--tz', '--match']; const USAGE = `Usage: node ics-interview.mjs # JSON: first VEVENT + a row for data/active-interviews.md node ics-interview.mjs < invite.ics # read the .ics from stdin node ics-interview.mjs --summary # human-readable node ics-interview.mjs --tz # display zone (default: profile location.timezone, then machine zone) node ics-interview.mjs --match "" # also suggest the tracker row (text, or a path to a file holding it) node ics-interview.mjs --help Read-only: prints a row to paste; it never writes data/active-interviews.md.`; function positionalOperand(args) { for (let i = 0; i < args.length; i += 1) { const a = args[i]; if (VALUE_FLAGS.includes(a)) { i += 1; continue; } if (!a.startsWith('-')) return a; } return null; } async function matchTracker(matchArg, event) { let text = matchArg; try { if (existsSync(matchArg)) text = readFileSync(matchArg, 'utf-8'); } catch { /* treat the argument as the invite text itself */ } // Imported on demand: invite-match.mjs reads process.argv at module load. const { analyzeInvite } = await import('./invite-match.mjs'); const result = analyzeInvite(text); return { classification: result.classification, company: result.signals.company, candidates: result.candidates.slice(0, 3), lengthMinutes: event?.durationMinutes ?? null, lengthSource: event?.durationMinutes != null ? '.ics' : null, }; } function printSummary(r) { const e = r.event; console.log(`\n${'='.repeat(70)}\n Interview .ics — career-ops\n${'='.repeat(70)}\n`); if (!e) { console.log(' No event could be read.'); } else { const line = (k, v) => console.log(` ${k.padEnd(22)}${v ?? DASH}`); line('Summary:', e.summary); line('Starts (as sent):', e.start ? `${e.start.local}${e.start.utc ? ' = ' + e.start.utc : ''}${e.start.tzid ? ' [' + e.start.tzid + ']' : ''} (${e.start.kind})` : null); line('Ends (as sent):', e.end ? `${e.end.local ?? DASH}${e.end.utc ? ' = ' + e.end.utc : ''}` : null); line('Length:', e.durationMinutes != null ? `${e.durationMinutes} min` : (e.allDay ? 'all-day' : 'not stated')); line(`In ${r.displayZone.zone}:`, e.display ? `${e.display.start.date} ${e.display.start.time} ${e.display.start.abbr}${e.display.end ? ' - ' + e.display.end.time : ''}` : null); line('Meeting link:', e.meetingUrl); line('Location:', e.location); line('Organizer:', e.organizer); line('Attendees:', e.attendees.length ? e.attendees.join(', ') : null); } for (const w of r.warnings) console.log(` ! ${w.code}: ${w.message}`); if (r.match) { console.log(`\n Tracker match (${r.match.classification}): ${r.match.company ?? '(no company found)'}`); for (const c of r.match.candidates) console.log(` #${c.appNumber} ${c.company} - ${c.role} [${c.status}]`); if (r.match.lengthMinutes != null) console.log(` Length from .ics: ${r.match.lengthMinutes} min`); } if (r.row) console.log(`\n Row for data/active-interviews.md:\n ${r.row}`); console.log(''); } async function main() { const args = process.argv.slice(2); validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true }); const operand = positionalOperand(args); let body; if (operand) { try { body = readFileSync(operand, 'utf-8'); } catch (e) { console.error(`ics-interview: cannot read ${operand}: ${e.code === 'ENOENT' ? 'file not found' : e.message}`); process.exit(1); } } else { try { body = readFileSync(0, 'utf-8'); } catch (e) { console.error(`ics-interview: no .ics file given and stdin could not be read (${e.message}). ${USAGE.split('\n')[1].trim()}`); process.exit(1); } } const warnings = []; const displayZone = resolveDisplayZone(flagValue(args, '--tz'), warnings); const parsed = parseIcs(body, { displayZone: displayZone.zone }); warnings.push(...parsed.warnings); let match = null; const matchArg = flagValue(args, '--match'); if (matchArg !== undefined) { try { match = await matchTracker(matchArg, parsed.event); } catch (e) { warnings.push({ code: 'match-failed', message: `invite-match could not run (${e.message}).` }); } } const top = match?.candidates?.[0] ?? null; const row = buildRow(parsed.event, top && top.nameScore === 1 && match.candidates.length === 1 ? top : null); const result = { event: parsed.event, displayZone, row, match, warnings }; if (args.includes('--summary')) printSummary(result); else console.log(JSON.stringify(result, null, 2)); process.exit(parsed.event ? 0 : 1); } if (isMainModule(import.meta.url)) { await main(); }