230 lines
6.8 KiB
TypeScript
230 lines
6.8 KiB
TypeScript
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<string, unknown>;
|
|
|
|
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<string, string>[] {
|
|
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<string, string>>((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<string, string>[]): 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<string, string>[], mainSkillSet: Set<string>): 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<ParsedSkillMatrix> {
|
|
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<string>();
|
|
const softwareSkills = parseSkillSheet(softwareRows, mainSkillSet);
|
|
const technicalSkills = parseSkillSheet(technicalRows, mainSkillSet);
|
|
|
|
// Merge: deduplicate by skill name (prefer higher proficiency)
|
|
const skillMap = new Map<string, SkillEntry>();
|
|
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;
|
|
}
|