/** * Turns lines typed (or pasted) onto the daily board into board items. * * The board replaces a shared doc, so the input is whatever someone would * have typed there: * * WATER DROPS: * DH2_VFX_027_210 still do (CHRIS) * DH2_VFX_005_010 gfx required for update @michelle wait: client GFX ADH_GFX_005_020 * * - The first shot-code-shaped token is the line's shot. It is matched against * the project's shot codes and client aliases; a code that matches nothing * is kept as typed so the board can flag it. * - `(NAME)` or `@name` assigns the line when NAME is a user's first or full * name. A parenthesised group can carry more than the name — "(MICHELLE * ROTO)" assigns Michelle and keeps "ROTO" in the text. * - `wait: …` (also `waiting on:`, `blocked by:`) marks what the line is * blocked on. The colon is required so prose like "awaiting gfx" stays text. * - A line with no shot code that ends in ":" or is all capitals is a heading; * the lines after it are grouped under it. */ export interface BoardShot { id: string; shotCode: string; clientCode?: string | null; } export interface BoardUser { id: string; name: string | null; email: string; } export interface ParsedBoardLine { text: string; /** The shot code as typed (canonical shotCode when it matched); null = no code on the line */ code: string | null; shotId: string | null; assigneeId: string | null; waitingOn: string | null; group: string; } // Three or more underscore-separated segments, starting with a letter: // DH2_VFX_027_090, UNG_117_023_010, ADH_GFX_005_020. export const CODE_RE = /\b[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+){2,}\b/; const WAIT_RE = /\b(?:wait(?:ing)?(?:\s+(?:on|for))?|blocked(?:\s+(?:on|by))?)\s*:\s*(.*)$/i; const BULLET_RE = /^(?:[-*•–]|\d+[.)])\s+/; export function findShot(code: string, shots: BoardShot[]): BoardShot | null { const c = code.toUpperCase(); return ( shots.find((s) => s.shotCode.toUpperCase() === c) ?? shots.find((s) => s.clientCode?.trim().toUpperCase() === c) ?? null ); } /** * A likely intended shot for a code that matched nothing — same leading * segment and same numeric segments, so "DH2_004_100" suggests * "DH2_VFX_004_100". Only a unique candidate is suggested. */ export function suggestShot(code: string, shots: BoardShot[]): BoardShot | null { const sig = (c: string) => { const parts = c.toUpperCase().split("_"); return `${parts[0]}|${parts.filter((p) => /^\d+$/.test(p)).join("_")}`; }; const want = sig(code); if (want.endsWith("|")) return null; // no numeric segments to go on const hits = shots.filter((s) => sig(s.shotCode) === want); return hits.length === 1 ? hits[0] : null; } function userNames(u: BoardUser): string[] { const names = [u.email.split("@")[0]]; if (u.name) { names.push(u.name); names.push(u.name.split(/\s+/)[0]); } return names.map((n) => n.toLowerCase()); } function findUser(word: string, users: BoardUser[]): BoardUser | null { const w = word.toLowerCase(); const hits = users.filter((u) => userNames(u).includes(w)); return hits.length === 1 ? hits[0] : null; } function isHeading(line: string): boolean { if (CODE_RE.test(line)) return false; if (line.endsWith(":")) return true; const letters = line.replace(/[^A-Za-z]/g, ""); return letters.length >= 3 && letters === letters.toUpperCase(); } export function parseBoardLine( raw: string, ctx: { shots: BoardShot[]; users: BoardUser[] }, group = "", ): ParsedBoardLine { let line = raw.trim().replace(BULLET_RE, ""); let waitingOn: string | null = null; const wait = WAIT_RE.exec(line); if (wait) { waitingOn = wait[1].trim() || null; line = line.slice(0, wait.index); } let code: string | null = null; let shotId: string | null = null; const codeMatch = CODE_RE.exec(line); if (codeMatch) { const shot = findShot(codeMatch[0], ctx.shots); code = shot?.shotCode ?? codeMatch[0].toUpperCase(); shotId = shot?.id ?? null; line = line.slice(0, codeMatch.index) + line.slice(codeMatch.index + codeMatch[0].length); } let assigneeId: string | null = null; // (NAME …) — first word a user's name; the rest of the group stays as text line = line.replace(/\(([^)]*)\)/g, (whole, inner: string) => { if (assigneeId) return whole; const words = inner.trim().split(/\s+/); // Full name first ("Chris Pinto"), then first word const full = findUser(words.slice(0, 2).join(" "), ctx.users); const user = full ?? findUser(words[0] ?? "", ctx.users); if (!user) return whole; assigneeId = user.id; const rest = words.slice(full ? 2 : 1).join(" "); return rest ? ` ${rest} ` : " "; }); line = line.replace(/@([A-Za-z][\w.-]*)/g, (whole, name: string) => { if (assigneeId) return whole; const user = findUser(name, ctx.users); if (!user) return whole; assigneeId = user.id; return " "; }); const text = line .replace(/\s+/g, " ") .replace(/^[\s:\-–,.]+|[\s:\-–,]+$/g, "") .trim(); return { text, code, shotId, assigneeId, waitingOn, group }; } /** Every non-blank line of `input` as a board item, headings applied as groups. */ export function parseBoardText( input: string, ctx: { shots: BoardShot[]; users: BoardUser[]; group?: string }, ): ParsedBoardLine[] { const out: ParsedBoardLine[] = []; let group = ctx.group ?? ""; const lines = input.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); for (const raw of lines) { const line = raw.replace(BULLET_RE, ""); // A single line is always an item — someone typing "Deposit received" // into the quick-add box means a note, not a heading. if (lines.length > 1 && isHeading(line)) { group = line.replace(/:$/, "").trim(); continue; } const parsed = parseBoardLine(line, ctx, group); if (parsed.text || parsed.code || parsed.waitingOn) out.push(parsed); } return out; }