/** * Pure parsing for `dench meetings import` and `dench meetings transcript * set`. No network, no Convex — everything here is unit-testable and shared * by the CLI and (through the OpenAPI description) documented for API * callers who parse client-side. * * A source row is "meeting fields plus three optional attachments". The * attachments live in side tables on the server and travel under reserved * keys so a CSV can carry them as ordinary columns: * * transcript string | string[] | segment[] → meetingTranscriptSegments * actionItems string | string[] | item[] → meetingActionItems * attendees string | string[] | person[] → meetingParticipants * * `attendees`, not `participants`: `Participants` is already the meeting's * relation to `people` rows (entry ids), and a column of names and emails * must not be mistaken for one. */ export type ImportedSegment = { seq?: number; speakerName?: string | null; speakerEmail?: string | null; startMs?: number; endMs?: number; text: string; }; export type ImportedActionItem = { text: string; ownerName?: string | null; done?: boolean; }; export type ImportedAttendee = { name?: string | null; email?: string | null; isHost?: boolean; }; export type MeetingImportRow = { fields: Record; transcript: ImportedSegment[] | null; actionItems: ImportedActionItem[] | null; attendees: ImportedAttendee[] | null; }; /** Reserved row keys, matched case- and separator-insensitively. */ const RESERVED = { transcript: new Set(["transcript", "transcriptsegments", "segments"]), actionItems: new Set(["actionitems", "actions", "todos", "tasks"]), attendees: new Set(["attendees", "speakers", "people"]), } as const; function normalizeKey(key: string): string { return key.toLowerCase().replace(/[\s_-]+/g, ""); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function asString(value: unknown): string | null { if (typeof value === "string") return value.trim() || null; if (typeof value === "number" && Number.isFinite(value)) return String(value); return null; } /** * `[01:02:03]`, `01:02:03`, `1:02`, `(12:34)`, `62.5` (seconds), `62500ms`. * Returns milliseconds, or null when the token is not a timestamp. */ export function parseOffsetMs(raw: unknown): number | null { if (typeof raw === "number") { return Number.isFinite(raw) && raw >= 0 ? Math.round(raw) : null; } if (typeof raw !== "string") return null; const token = raw.trim().replace(/^[[(]|[\])]$/g, ""); if (!token) return null; const ms = token.match(/^(\d+)\s*ms$/i); if (ms) return Number(ms[1]); const clock = token.match( /^(?:(\d{1,2}):)?(\d{1,2}):(\d{2})(?:[.,](\d{1,3}))?$/, ); if (clock) { const [, h, m, s, frac] = clock; const millis = frac ? Number(frac.padEnd(3, "0")) : 0; return ((Number(h ?? 0) * 60 + Number(m)) * 60 + Number(s)) * 1000 + millis; } const seconds = token.match(/^(\d+(?:\.\d+)?)\s*s?$/); if (seconds) return Math.round(Number(seconds[1]) * 1000); return null; } /** * A leading timestamp in any of the usual shapes, followed by an optional * `Speaker:` label. The label is capped at 60 characters and may not contain * sentence punctuation, so a colon later in a sentence ("We agreed. Next * step: ship it.") never splits a turn; only the first `Word(s):` of a line * can be a speaker. */ const LINE_RE = /^\s*(?:[[(]?((?:\d{1,2}:)?\d{1,2}:\d{2}(?:[.,]\d{1,3})?)[\])]?\s*[-–—]?\s*)?(?:([^:.!?\n]{1,60}?)\s*(?:[[(]((?:\d{1,2}:)?\d{1,2}:\d{2}(?:[.,]\d{1,3})?)[\])])?\s*:\s+)?(.*)$/; /** * Plain-text transcript → segments. One non-empty line is one segment; a * line without a speaker label inherits the previous speaker. Timestamps * are honoured when present, and a segment's `endMs` is the next timed * segment's start. When nothing in the file is timed every offset is 0 — * the UI hides the clock rather than inventing one. */ export function parseTranscriptText(text: string): ImportedSegment[] { const segments: ImportedSegment[] = []; let speaker: string | null = null; for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim(); if (!line) continue; const match = line.match(LINE_RE); if (!match) { segments.push({ speakerName: speaker, text: line }); continue; } const [, leadingTs, label, labelTs, rest] = match; const body = (rest ?? "").trim(); if (!body && !label) continue; if (label) speaker = label.trim(); const startMs = parseOffsetMs(leadingTs ?? labelTs ?? null); segments.push({ speakerName: speaker, text: body || (label ?? ""), ...(startMs !== null ? { startMs } : {}), }); } return fillSegmentTiming(segments); } /** Assign `seq` and close each timed segment at the next timed start. */ export function fillSegmentTiming( segments: ImportedSegment[], ): ImportedSegment[] { const timed = segments.some((segment) => (segment.startMs ?? 0) > 0); let previousEnd = 0; return segments.map((segment, index) => { const startMs = timed ? Math.max(segment.startMs ?? previousEnd, previousEnd) : 0; let endMs = segment.endMs ?? undefined; if (endMs === undefined && timed) { const next = segments .slice(index + 1) .find((candidate) => candidate.startMs !== undefined); endMs = next?.startMs !== undefined ? Math.max(next.startMs, startMs) : startMs; } previousEnd = endMs ?? startMs; return { seq: segment.seq ?? index, speakerName: segment.speakerName ?? null, speakerEmail: segment.speakerEmail ?? null, startMs, endMs: endMs ?? startMs, text: segment.text, }; }); } /** Structured segment objects (JSON/JSONL) in any of the common key spellings. */ export function normalizeSegment(value: unknown): ImportedSegment | null { if (typeof value === "string") { const [segment] = parseTranscriptText(value); return segment ?? null; } if (!isRecord(value)) return null; const text = asString(value.text) ?? asString(value.content) ?? asString(value.line); if (!text) return null; const speakerName = asString(value.speakerName) ?? asString(value.speaker) ?? asString(value.name); const speakerEmail = asString(value.speakerEmail) ?? asString(value.email) ?? null; // `startMs`/`endMs` are milliseconds. The un-suffixed keys most exports // use (`start`, `end`, `timestamp`, `offset`) are SECONDS when numeric — // Fireflies, Otter and Granola all count seconds — and clock strings // otherwise. const secondsOrClock = (raw: unknown): number | null => typeof raw === "number" ? Number.isFinite(raw) && raw >= 0 ? Math.round(raw * 1000) : null : parseOffsetMs(raw); const startMs = parseOffsetMs(value.startMs) ?? secondsOrClock(value.start) ?? secondsOrClock(value.timestamp) ?? secondsOrClock(value.offset); const endMs = parseOffsetMs(value.endMs) ?? secondsOrClock(value.end); const seq = typeof value.seq === "number" ? value.seq : undefined; return { ...(seq !== undefined ? { seq } : {}), speakerName, speakerEmail, ...(startMs !== null ? { startMs } : {}), ...(endMs !== null ? { endMs } : {}), text, }; } export function normalizeTranscript(value: unknown): ImportedSegment[] | null { if (value === null || value === undefined || value === "") return null; if (typeof value === "string") { const trimmed = value.trim(); if (trimmed.startsWith("[")) { try { return normalizeTranscript(JSON.parse(trimmed)); } catch { // Not JSON after all — treat as text. } } return parseTranscriptText(value); } if (Array.isArray(value)) { const segments = value .map(normalizeSegment) .filter((segment): segment is ImportedSegment => segment !== null); return fillSegmentTiming(segments); } return null; } const LINE_BREAK = /[\r\n\u2028\u2029]/; /** Inspect only the final owner delimiter instead of backtracking over the body. */ function actionItemOwner( text: string, ): { text: string; ownerName: string } | null { if (text.endsWith(")")) { const open = text.lastIndexOf("("); const owner = text.slice(open + 1, -1); const body = text.slice(0, open).trimEnd(); if ( open >= 0 && owner.length > 0 && owner.length <= 60 && !owner.includes(")") && !LINE_BREAK.test(body) ) { return { text: body.trim(), ownerName: owner.trim() }; } } for (let index = text.length - 1; index >= 0; index--) { if (!"-–—".includes(text[index])) continue; // Only the last dash can separate an owner: owners cannot contain dashes. if ( !/\s/.test(text[index - 1] ?? "") || !/\s/.test(text[index + 1] ?? "") ) { return null; } const owner = text.slice(index + 1).trimStart(); const body = text.slice(0, index).trimEnd(); return owner.length > 0 && owner.length <= 60 && !LINE_BREAK.test(body) ? { text: body.trim(), ownerName: owner.trim() } : null; } return null; } /** * `- [x] Send the deck (Hank)` / `Send the deck — Hank` / `{text, owner, * done}`. Markdown checkboxes carry completion; a trailing parenthesised * or dashed name is the owner. */ export function normalizeActionItem(value: unknown): ImportedActionItem | null { if (typeof value === "string") { let text = value.trim().replace(/^[-*•]\s+/, ""); if (!text) return null; let done: boolean | undefined; if (text[0] === "[" && " xX".includes(text[1] ?? "") && text[2] === "]") { const body = text.slice(3).trimStart(); if (!LINE_BREAK.test(body)) { done = text[1] !== " "; text = body.trim(); } } let ownerName: string | null = null; const owner = actionItemOwner(text); if (owner) { text = owner.text; ownerName = owner.ownerName; } if (!text) return null; return { text, ownerName, ...(done !== undefined ? { done } : {}) }; } if (!isRecord(value)) return null; const text = asString(value.text) ?? asString(value.title) ?? asString(value.name); if (!text) return null; const ownerName = asString(value.ownerName) ?? asString(value.owner) ?? asString(value.assignee) ?? null; const done = typeof value.done === "boolean" ? value.done : typeof value.completed === "boolean" ? value.completed : undefined; return { text, ownerName, ...(done !== undefined ? { done } : {}) }; } export function normalizeActionItems( value: unknown, ): ImportedActionItem[] | null { if (value === null || value === undefined || value === "") return null; if (typeof value === "string") { const trimmed = value.trim(); if (trimmed.startsWith("[")) { try { return normalizeActionItems(JSON.parse(trimmed)); } catch { // fall through to line splitting } } return normalizeActionItems(trimmed.split(/\r?\n/)); } if (Array.isArray(value)) { return value .map(normalizeActionItem) .filter((item): item is ImportedActionItem => item !== null); } return null; } /** `Hank Scorpio ` / `hank@globex.test` / `Hank Scorpio`. */ export function normalizeAttendee(value: unknown): ImportedAttendee | null { if (typeof value === "string") { const raw = value.trim(); if (!raw) return null; const markerStart = raw.lastIndexOf("("); const marker = raw.slice(markerStart).toLowerCase(); const isHost = ["(host)", "(organizer)", "(organiser)"].includes(marker); const cleaned = isHost ? raw.slice(0, markerStart).trimEnd() : raw; if (cleaned.endsWith(">")) { const open = cleaned.lastIndexOf("<"); const email = cleaned.slice(open + 1, -1); const at = email.indexOf("@", 1); const name = cleaned.slice(0, open).trimEnd(); if ( open >= 0 && at >= 1 && at < email.length - 1 && !/[<>\s]/.test(email) && !LINE_BREAK.test(name) ) { return { name: name.trim() || null, email, ...(isHost ? { isHost } : {}), }; } } if (/^[^\s@]+@[^\s@]+$/.test(cleaned)) { return { name: null, email: cleaned, ...(isHost ? { isHost } : {}) }; } return { name: cleaned, email: null, ...(isHost ? { isHost } : {}) }; } if (!isRecord(value)) return null; const name = asString(value.name) ?? asString(value.displayName) ?? null; const email = asString(value.email) ?? null; if (!name && !email) return null; const isHost = typeof value.isHost === "boolean" ? value.isHost : typeof value.host === "boolean" ? value.host : undefined; return { name, email, ...(isHost !== undefined ? { isHost } : {}) }; } export function normalizeAttendees(value: unknown): ImportedAttendee[] | null { if (value === null || value === undefined || value === "") return null; if (typeof value === "string") { const trimmed = value.trim(); if (trimmed.startsWith("[")) { try { return normalizeAttendees(JSON.parse(trimmed)); } catch { // fall through } } // A comma splits unless the next angle bracket is a closing one. Track // that bracket while scanning backwards instead of rescanning the tail // for every comma. Semicolons and newlines always separate attendees. const parts: string[] = []; let end = trimmed.length; let nextAngleIsClose = false; for (let index = trimmed.length - 1; index >= 0; index--) { const char = trimmed[index]; if (char === ">") nextAngleIsClose = true; else if (char === "<") nextAngleIsClose = false; else if ( char === ";" || char === "\n" || (char === "," && !nextAngleIsClose) ) { parts.push(trimmed.slice(index + 1, end)); end = index; } } parts.push(trimmed.slice(0, end)); return normalizeAttendees(parts.reverse()); } if (Array.isArray(value)) { return value .map(normalizeAttendee) .filter((row): row is ImportedAttendee => row !== null); } return null; } /** * Split one source row into meeting fields and its attachments. Reserved * keys are removed from `fields` so the CRM write never sees them; an empty * reserved cell (blank CSV column) yields `null`, meaning "nothing to do". */ export function splitMeetingImportRow( row: Record, ): MeetingImportRow { const fields: Record = {}; let transcript: unknown; let actionItems: unknown; let attendees: unknown; for (const [key, value] of Object.entries(row)) { const normalized = normalizeKey(key); if (RESERVED.transcript.has(normalized)) transcript = value; else if (RESERVED.actionItems.has(normalized)) actionItems = value; else if (RESERVED.attendees.has(normalized)) attendees = value; else fields[key] = value; } return { fields, transcript: normalizeTranscript(transcript), actionItems: normalizeActionItems(actionItems), attendees: normalizeAttendees(attendees), }; } /** * Fill in what the Meetings UI needs and the source usually omits. * * `Status` defaults to `Completed`: an imported call already happened. An * `Ended At` is derived from `Started At` + `Recording Duration` when the * source gave a length but no end. Nothing else is invented — a missing * `Started At` is reported by the caller, not guessed. */ export function applyMeetingFieldDefaults( fields: Record, ): Record { const out = { ...fields }; if (asString(out.Status) === null) out.Status = "Completed"; const startedAt = parseDateMs(out["Started At"]); const endedAt = parseDateMs(out["Ended At"]); const duration = parseSeconds(out["Recording Duration"]); if (startedAt !== null && endedAt === null && duration !== null) { out["Ended At"] = startedAt + duration * 1000; } if ( startedAt !== null && endedAt !== null && duration === null && endedAt > startedAt ) { out["Recording Duration"] = Math.round((endedAt - startedAt) / 1000); } return out; } export function parseDateMs(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value !== "string" || !value.trim()) return null; const ts = Date.parse(value); return Number.isFinite(ts) ? ts : null; } function parseSeconds(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value !== "string" || !value.trim()) return null; const n = Number(value); return Number.isFinite(n) ? n : null; }