import type { SkillEntry } from "@capakraken/shared"; import { assertHeaderRow, assertTabularMatrixWithinLimits } from "./excel.js"; type ExcelJsModule = typeof import("exceljs"); let _excelJs: ExcelJsModule | null = null; async function getExcelJS() { if (!_excelJs) { _excelJs = await import("exceljs"); } return _excelJs; } function normalizeCellString(value: unknown): string { if (value === undefined || value === null) { return ""; } if (value instanceof Date) { return value.toISOString(); } if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { return String(value); } if (typeof value === "object") { const record = value as Record; if ("result" in record) { return normalizeCellString(record.result); } if ("text" in record && typeof record.text === "string") { return record.text; } if ("hyperlink" in record && typeof record.hyperlink === "string") { return record.hyperlink; } if ("richText" in record && Array.isArray(record.richText)) { return record.richText .map((part) => { if (part && typeof part === "object" && "text" in part) { const text = (part as { text?: unknown }).text; return typeof text === "string" ? text : ""; } return ""; }) .join(""); } } return String(value); } function worksheetToRowObjects( worksheet: { rowCount: number; getRow: (rowNumber: number) => { cellCount: number; getCell: (columnNumber: number) => { value: unknown }; }; } | undefined, ): Record[] { if (!worksheet) { return []; } const rows: string[][] = []; for (let rowNumber = 1; rowNumber <= worksheet.rowCount; rowNumber += 1) { const row = worksheet.getRow(rowNumber); const cells: string[] = []; for (let columnNumber = 1; columnNumber <= row.cellCount; columnNumber += 1) { cells.push(normalizeCellString(row.getCell(columnNumber).value)); } rows.push(cells); } assertTabularMatrixWithinLimits(rows, "skill matrix import"); const headers = (rows[0] ?? []).map((header) => header.trim()); assertHeaderRow(headers, "skill matrix import"); if (headers.length === 0) { return []; } return rows .slice(1) .filter((row) => row.some((value) => value.trim() !== "")) .map((row) => headers.reduce>((record, header, index) => { record[header] = row[index] ?? ""; return record; }, {}), ); } export interface ParsedEmployeeInfo { displayName?: string; areaOfExpertise?: string; yearsOfExperience?: number; portfolioUrl?: string; } export interface ParsedSkillMatrix { employeeInfo: ParsedEmployeeInfo; skills: SkillEntry[]; } // Maps Excel proficiency (1-4) → plANARCHY proficiency (2-5) function mapProficiency(raw: string): 1 | 2 | 3 | 4 | 5 | null { const n = parseInt(raw, 10); if (isNaN(n) || n === 0) return null; if (n === 1) return 2; if (n === 2) return 3; if (n === 3) return 4; if (n === 4) return 5; return null; } function parseEmployeeInfo(rows: Record[]): ParsedEmployeeInfo { const info: ParsedEmployeeInfo = {}; for (const row of rows) { const item = (row["item"] ?? "").toLowerCase().trim(); const value = (row["property"] ?? "").trim(); if (!value || value === "please select") continue; if (item.includes("full name")) { info.displayName = value; } else if (item.includes("area of expertise") || item.includes("main focus")) { info.areaOfExpertise = value; } else if (item.includes("years of experience")) { const n = parseFloat(value); if (!isNaN(n)) info.yearsOfExperience = Math.round(n); } else if (item.includes("portfolio") || item.includes("url") || item.includes("linkedin") || item.includes("artstation")) { if (value.startsWith("http")) info.portfolioUrl = value; } } return info; } function parseSkillSheet(rows: Record[], mainSkillSet: Set): SkillEntry[] { const skills: SkillEntry[] = []; let mainSkillCount = 0; for (const row of rows) { const category = (row["category"] ?? "").trim(); const item = (row["item"] ?? "").trim(); const property = (row["property"] ?? "").trim(); const mainSkillRaw = (row["main skillset"] ?? "").trim(); if (!item) continue; const proficiency = mapProficiency(property); if (proficiency === null) continue; // skip 0 / no experience const isMainSkillCandidate = mainSkillRaw === "1" || mainSkillRaw === "2"; const isMainSkill = isMainSkillCandidate && mainSkillCount < 2; if (isMainSkill) { mainSkillSet.add(item); mainSkillCount++; } skills.push({ skill: item, ...(category ? { category } : {}), proficiency, ...(isMainSkillCandidate ? { isMainSkill: true } : {}), }); } return skills; } /** * Parse a skill matrix workbook (xlsx ArrayBuffer) into structured data. * Returns ParsedSkillMatrix with employeeInfo and merged skills array. */ export async function parseSkillMatrixWorkbook(data: ArrayBuffer): Promise { const ExcelJS = await getExcelJS(); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(data); const employeeRows = worksheetToRowObjects(workbook.getWorksheet("Employee Information")); const softwareRows = worksheetToRowObjects(workbook.getWorksheet("Software Skills")); const technicalRows = worksheetToRowObjects(workbook.getWorksheet("Technical Skillset")); const employeeInfo = parseEmployeeInfo(employeeRows); // Track main skills across both sheets (max 2 total) const mainSkillSet = new Set(); const softwareSkills = parseSkillSheet(softwareRows, mainSkillSet); const technicalSkills = parseSkillSheet(technicalRows, mainSkillSet); // Merge: deduplicate by skill name (prefer higher proficiency) const skillMap = new Map(); for (const s of [...softwareSkills, ...technicalSkills]) { const existing = skillMap.get(s.skill); if (!existing || s.proficiency > existing.proficiency) { skillMap.set(s.skill, s); } } return { employeeInfo, skills: Array.from(skillMap.values()), }; } /** * Fuzzy match an areaOfExpertise string against a list of role names. * Returns the best matching role name or null. */ export function matchRoleName(areaOfExpertise: string, roleNames: string[]): string | null { if (!areaOfExpertise) return null; const needle = areaOfExpertise.toLowerCase().trim(); // Exact match first const exact = roleNames.find((r) => r.toLowerCase() === needle); if (exact) return exact; // Partial match (needle contains role name or vice versa) const partial = roleNames.find( (r) => needle.includes(r.toLowerCase()) || r.toLowerCase().includes(needle), ); return partial ?? null; }