resume-parser-ts
Version:
A TypeScript library for parsing resumes from PDF files
1 lines • 75.2 kB
Source Map (JSON)
{"version":3,"sources":["../src/index.ts","../src/lib/parse-resume-from-pdf/read-pdf.ts","../src/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points.ts","../src/lib/parse-resume-from-pdf/group-text-items-into-lines.ts","../src/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features.ts","../src/lib/parse-resume-from-pdf/group-lines-into-sections.ts","../src/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/get-section-lines.ts","../src/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/feature-scoring-system.ts","../src/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-profile.ts","../src/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/subsections.ts","../src/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-education.ts","../src/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-work-experience.ts","../src/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-project.ts","../src/lib/deep-clone.ts","../src/lib/redux/resumeSlice.ts","../src/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-skills.ts","../src/lib/parse-resume-from-pdf/extract-resume-from-sections/index.ts","../src/lib/parse-resume-from-pdf/index.ts"],"sourcesContent":["export { parseResumeFromPdf } from './lib/parse-resume-from-pdf/index.js';\nexport type { \n Resume, \n ResumeProfile, \n ResumeWorkExperience, \n ResumeEducation, \n ResumeProject, \n ResumeSkills,\n ResumeCustom,\n FeaturedSkill\n} from './lib/redux/types.js';\nexport type { \n TextItem, \n TextItems, \n Line, \n Lines \n} from './lib/parse-resume-from-pdf/types.js';\n","// Getting pdfjs to work is tricky. The following 3 lines would make it work\r\n// https://stackoverflow.com/a/63486898/7699841\r\nimport * as pdfjs from \"pdfjs-dist\";\r\n// @ts-ignore\r\nimport pdfjsWorker from \"pdfjs-dist/build/pdf.worker.entry\";\r\npdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;\r\n\r\nimport type { TextItem as PdfjsTextItem } from \"pdfjs-dist/types/src/display/api\";\r\nimport type { TextItem, TextItems } from \"../../lib/parse-resume-from-pdf/types\";\r\n\r\n/**\r\n * Step 1: Read pdf and output textItems by concatenating results from each page.\r\n *\r\n * To make processing easier, it returns a new TextItem type, which removes unused\r\n * attributes (dir, transform), adds x and y positions, and replaces loaded font\r\n * name with original font name.\r\n *\r\n * @example\r\n * const onFileChange = async (e) => {\r\n * const fileUrl = URL.createObjectURL(e.target.files[0]);\r\n * const textItems = await readPdf(fileUrl);\r\n * }\r\n */\r\nexport const readPdf = async (fileUrl: string): Promise<TextItems> => {\r\n const pdfFile = await pdfjs.getDocument(fileUrl).promise;\r\n let textItems: TextItems = [];\r\n\r\n for (let i = 1; i <= pdfFile.numPages; i++) {\r\n // Parse each page into text content\r\n const page = await pdfFile.getPage(i);\r\n const textContent = await page.getTextContent();\r\n\r\n // Wait for font data to be loaded\r\n await page.getOperatorList();\r\n const commonObjs = page.commonObjs;\r\n\r\n // Convert Pdfjs TextItem type to new TextItem type\r\n const pageTextItems = textContent.items.map((item) => {\r\n const {\r\n str: text,\r\n dir, // Remove text direction\r\n transform,\r\n fontName: pdfFontName,\r\n ...otherProps\r\n } = item as PdfjsTextItem;\r\n\r\n // Extract x, y position of text item from transform.\r\n // As a side note, origin (0, 0) is bottom left.\r\n // Reference: https://github.com/mozilla/pdf.js/issues/5643#issuecomment-496648719\r\n const x = transform[4];\r\n const y = transform[5];\r\n\r\n // Use commonObjs to convert font name to original name (e.g. \"GVDLYI+Arial-BoldMT\")\r\n // since non system font name by default is a loaded name, e.g. \"g_d8_f1\"\r\n // Reference: https://github.com/mozilla/pdf.js/pull/15659\r\n const fontObj = commonObjs.get(pdfFontName);\r\n const fontName = fontObj.name;\r\n\r\n // pdfjs reads a \"-\" as \"-‐\" in the resume example. This is to revert it.\r\n // Note \"-‐\" is \"-­‐\" with a soft hyphen in between. It is not the same as \"--\"\r\n const newText = text.replace(/-‐/g, \"-\");\r\n\r\n const newItem = {\r\n ...otherProps,\r\n fontName,\r\n text: newText,\r\n x,\r\n y,\r\n };\r\n return newItem;\r\n });\r\n\r\n // Some pdf's text items are not in order. This is most likely a result of creating it\r\n // from design softwares, e.g. canvas. The commented out method can sort pageTextItems\r\n // by y position to put them back in order. But it is not used since it might be more\r\n // helpful to let users know that the pdf is not in order.\r\n // pageTextItems.sort((a, b) => Math.round(b.y) - Math.round(a.y));\r\n\r\n // Add text items of each page to total\r\n textItems.push(...pageTextItems);\r\n }\r\n\r\n // Filter out empty space textItem noise\r\n const isEmptySpace = (textItem: TextItem) =>\r\n !textItem.hasEOL && textItem.text.trim() === \"\";\r\n textItems = textItems.filter((textItem) => !isEmptySpace(textItem));\r\n\r\n return textItems;\r\n};\r\n","import type { Lines, TextItem } from \"../../../../lib/parse-resume-from-pdf/types\";\r\n\r\n/**\r\n * List of bullet points\r\n * Reference: https://stackoverflow.com/questions/56540160/why-isnt-there-a-medium-small-black-circle-in-unicode\r\n * U+22C5 DOT OPERATOR (⋅)\r\n * U+2219 BULLET OPERATOR (∙)\r\n * U+1F784 BLACK SLIGHTLY SMALL CIRCLE (🞄)\r\n * U+2022 BULLET (•) -------- most common\r\n * U+2981 Z NOTATION SPOT (⦁)\r\n * U+26AB MEDIUM BLACK CIRCLE (⚫︎)\r\n * U+25CF BLACK CIRCLE (●)\r\n * U+2B24 BLACK LARGE CIRCLE (⬤)\r\n * U+26AC MEDIUM SMALL WHITE CIRCLE ⚬\r\n * U+25CB WHITE CIRCLE ○\r\n */\r\nexport const BULLET_POINTS = [\r\n \"⋅\",\r\n \"∙\",\r\n \"🞄\",\r\n \"•\",\r\n \"⦁\",\r\n \"⚫︎\",\r\n \"●\",\r\n \"⬤\",\r\n \"⚬\",\r\n \"○\",\r\n];\r\n\r\n/**\r\n * Convert bullet point lines into a string array aka descriptions.\r\n */\r\nexport const getBulletPointsFromLines = (lines: Lines): string[] => {\r\n // Simply return all lines with text item joined together if there is no bullet point\r\n const firstBulletPointLineIndex = getFirstBulletPointLineIdx(lines);\r\n if (firstBulletPointLineIndex === undefined) {\r\n return lines.map((line) => line.map((item) => item.text).join(\" \"));\r\n }\r\n\r\n // Otherwise, process and remove bullet points\r\n\r\n // Combine all lines into a single string\r\n let lineStr = \"\";\r\n for (let item of lines.flat()) {\r\n const text = item.text;\r\n // Make sure a space is added between 2 words\r\n if (!lineStr.endsWith(\" \") && !text.startsWith(\" \")) {\r\n lineStr += \" \";\r\n }\r\n lineStr += text;\r\n }\r\n\r\n // Get the most common bullet point\r\n const commonBulletPoint = getMostCommonBulletPoint(lineStr);\r\n\r\n // Start line string from the beginning of the first bullet point\r\n const firstBulletPointIndex = lineStr.indexOf(commonBulletPoint);\r\n if (firstBulletPointIndex !== -1) {\r\n lineStr = lineStr.slice(firstBulletPointIndex);\r\n }\r\n\r\n // Divide the single string using bullet point as divider\r\n return lineStr\r\n .split(commonBulletPoint)\r\n .map((text) => text.trim())\r\n .filter((text) => !!text);\r\n};\r\n\r\nconst getMostCommonBulletPoint = (str: string): string => {\r\n const bulletToCount: { [bullet: string]: number } = BULLET_POINTS.reduce(\r\n (acc: { [bullet: string]: number }, cur) => {\r\n acc[cur] = 0;\r\n return acc;\r\n },\r\n {}\r\n );\r\n let bulletWithMostCount = BULLET_POINTS[0];\r\n let bulletMaxCount = 0;\r\n for (let char of str) {\r\n if (bulletToCount.hasOwnProperty(char)) {\r\n bulletToCount[char]++;\r\n if (bulletToCount[char] > bulletMaxCount) {\r\n bulletWithMostCount = char;\r\n }\r\n }\r\n }\r\n return bulletWithMostCount;\r\n};\r\n\r\nconst getFirstBulletPointLineIdx = (lines: Lines): number | undefined => {\r\n for (let i = 0; i < lines.length; i++) {\r\n for (let item of lines[i]) {\r\n if (BULLET_POINTS.some((bullet) => item.text.includes(bullet))) {\r\n return i;\r\n }\r\n }\r\n }\r\n return undefined;\r\n};\r\n\r\n// Only consider words that don't contain numbers\r\nconst isWord = (str: string) => /^[^0-9]+$/.test(str);\r\nconst hasAtLeast8Words = (item: TextItem) =>\r\n item.text.split(/\\s/).filter(isWord).length >= 8;\r\n\r\nexport const getDescriptionsLineIdx = (lines: Lines): number | undefined => {\r\n // The main heuristic to determine descriptions is to check if has bullet point\r\n let idx = getFirstBulletPointLineIdx(lines);\r\n\r\n // Fallback heuristic if the main heuristic doesn't apply (e.g. LinkedIn resume) to\r\n // check if the line has at least 8 words\r\n if (idx === undefined) {\r\n for (let i = 0; i < lines.length; i++) {\r\n const line = lines[i];\r\n if (line.length === 1 && hasAtLeast8Words(line[0])) {\r\n idx = i;\r\n break;\r\n }\r\n }\r\n }\r\n\r\n return idx;\r\n};\r\n","import { BULLET_POINTS } from \"../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points\";\r\nimport type { TextItems, Line, Lines } from \"../../lib/parse-resume-from-pdf/types\";\r\n\r\n/**\r\n * Step 2: Group text items into lines. This returns an array where each position\r\n * contains text items in the same line of the pdf file.\r\n */\r\nexport const groupTextItemsIntoLines = (textItems: TextItems): Lines => {\r\n const lines: Lines = [];\r\n\r\n // Group text items into lines based on hasEOL\r\n let line: Line = [];\r\n for (let item of textItems) {\r\n // If item is EOL, add current line to lines and start a new empty line\r\n if (item.hasEOL) {\r\n if (item.text.trim() !== \"\") {\r\n line.push({ ...item });\r\n }\r\n lines.push(line);\r\n line = [];\r\n }\r\n // Otherwise, add item to current line\r\n else if (item.text.trim() !== \"\") {\r\n line.push({ ...item });\r\n }\r\n }\r\n // Add last line if there is item in last line\r\n if (line.length > 0) {\r\n lines.push(line);\r\n }\r\n\r\n // Many pdf docs are not well formatted, e.g. due to converting from other docs.\r\n // This creates many noises, where a single text item is divided into multiple\r\n // ones. This step is to merge adjacent text items if their distance is smaller\r\n // than a typical char width to filter out those noises.\r\n const typicalCharWidth = getTypicalCharWidth(lines.flat());\r\n for (let line of lines) {\r\n // Start from the end of the line to make things easier to merge and delete\r\n for (let i = line.length - 1; i > 0; i--) {\r\n const currentItem = line[i];\r\n const leftItem = line[i - 1];\r\n const leftItemXEnd = leftItem.x + leftItem.width;\r\n const distance = currentItem.x - leftItemXEnd;\r\n if (distance <= typicalCharWidth) {\r\n if (shouldAddSpaceBetweenText(leftItem.text, currentItem.text)) {\r\n leftItem.text += \" \";\r\n }\r\n leftItem.text += currentItem.text;\r\n // Update leftItem width to include currentItem after merge before deleting current item\r\n const currentItemXEnd = currentItem.x + currentItem.width;\r\n leftItem.width = currentItemXEnd - leftItem.x;\r\n line.splice(i, 1);\r\n }\r\n }\r\n }\r\n\r\n return lines;\r\n};\r\n\r\n// Sometimes a space is lost while merging adjacent text items. This accounts for some of those cases\r\nconst shouldAddSpaceBetweenText = (leftText: string, rightText: string) => {\r\n const leftTextEnd = leftText[leftText.length - 1];\r\n const rightTextStart = rightText[0];\r\n const conditions = [\r\n [\":\", \",\", \"|\", \".\", ...BULLET_POINTS].includes(leftTextEnd) &&\r\n rightTextStart !== \" \",\r\n leftTextEnd !== \" \" && [\"|\", ...BULLET_POINTS].includes(rightTextStart),\r\n ];\r\n\r\n return conditions.some((condition) => condition);\r\n};\r\n\r\n/**\r\n * Return the width of a typical character. (Helper util for groupTextItemsIntoLines)\r\n *\r\n * A pdf file uses different characters, each with different width due to different\r\n * font family and font size. This util first extracts the most typically used font\r\n * family and font height, and compute the average character width using text items\r\n * that match the typical font family and height.\r\n */\r\nconst getTypicalCharWidth = (textItems: TextItems): number => {\r\n // Exclude empty space \" \" in calculations since its width isn't precise\r\n textItems = textItems.filter((item) => item.text.trim() !== \"\");\r\n\r\n const heightToCount: { [height: number]: number } = {};\r\n let commonHeight = 0;\r\n let heightMaxCount = 0;\r\n\r\n const fontNameToCount: { [fontName: string]: number } = {};\r\n let commonFontName = \"\";\r\n let fontNameMaxCount = 0;\r\n\r\n for (let item of textItems) {\r\n const { text, height, fontName } = item;\r\n // Process height\r\n if (!heightToCount[height]) {\r\n heightToCount[height] = 0;\r\n }\r\n heightToCount[height]++;\r\n if (heightToCount[height] > heightMaxCount) {\r\n commonHeight = height;\r\n heightMaxCount = heightToCount[height];\r\n }\r\n\r\n // Process font name\r\n if (!fontNameToCount[fontName]) {\r\n fontNameToCount[fontName] = 0;\r\n }\r\n fontNameToCount[fontName] += text.length;\r\n if (fontNameToCount[fontName] > fontNameMaxCount) {\r\n commonFontName = fontName;\r\n fontNameMaxCount = fontNameToCount[fontName];\r\n }\r\n }\r\n\r\n // Find the text items that match common font family and height\r\n const commonTextItems = textItems.filter(\r\n (item) => item.fontName === commonFontName && item.height === commonHeight\r\n );\r\n // Aggregate total width and number of characters of all common text items\r\n const [totalWidth, numChars] = commonTextItems.reduce(\r\n (acc, cur) => {\r\n const [preWidth, prevChars] = acc;\r\n return [preWidth + cur.width, prevChars + cur.text.length];\r\n },\r\n [0, 0]\r\n );\r\n const typicalCharWidth = totalWidth / numChars;\r\n\r\n return typicalCharWidth;\r\n};\r\n","import type { TextItem, FeatureSet } from \"../../../../lib/parse-resume-from-pdf/types\";\r\n\r\nconst isTextItemBold = (fontName: string) =>\r\n fontName.toLowerCase().includes(\"bold\");\r\nexport const isBold = (item: TextItem) => isTextItemBold(item.fontName);\r\nexport const hasLetter = (item: TextItem) => /[a-zA-Z]/.test(item.text);\r\nexport const hasNumber = (item: TextItem) => /[0-9]/.test(item.text);\r\nexport const hasComma = (item: TextItem) => item.text.includes(\",\");\r\nexport const getHasText = (text: string) => (item: TextItem) =>\r\n item.text.includes(text);\r\nexport const hasOnlyLettersSpacesAmpersands = (item: TextItem) =>\r\n /^[A-Za-z\\s&]+$/.test(item.text);\r\nexport const hasLetterAndIsAllUpperCase = (item: TextItem) =>\r\n hasLetter(item) && item.text.toUpperCase() === item.text;\r\n\r\n// Date Features\r\nconst hasYear = (item: TextItem) => /(?:19|20)\\d{2}/.test(item.text);\r\n// prettier-ignore\r\nconst MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\r\nconst hasMonth = (item: TextItem) =>\r\n MONTHS.some(\r\n (month) =>\r\n item.text.includes(month) || item.text.includes(month.slice(0, 4))\r\n );\r\nconst SEASONS = [\"Summer\", \"Fall\", \"Spring\", \"Winter\"];\r\nconst hasSeason = (item: TextItem) =>\r\n SEASONS.some((season) => item.text.includes(season));\r\nconst hasPresent = (item: TextItem) => item.text.includes(\"Present\");\r\nexport const DATE_FEATURE_SETS: FeatureSet[] = [\r\n [hasYear, 1],\r\n [hasMonth, 1],\r\n [hasSeason, 1],\r\n [hasPresent, 1],\r\n [hasComma, -1],\r\n];\r\n","import type { ResumeKey } from \"../../lib/redux/types\";\r\nimport type {\r\n Line,\r\n Lines,\r\n ResumeSectionToLines,\r\n} from \"../../lib/parse-resume-from-pdf/types\";\r\nimport {\r\n hasLetterAndIsAllUpperCase,\r\n hasOnlyLettersSpacesAmpersands,\r\n isBold,\r\n} from \"../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features\";\r\n\r\nexport const PROFILE_SECTION: ResumeKey = \"profile\";\r\n\r\n/**\r\n * Step 3. Group lines into sections\r\n *\r\n * Every section (except the profile section) starts with a section title that\r\n * takes up the entire line. This is a common pattern not just in resumes but\r\n * also in books and blogs. The resume parser uses this pattern to group lines\r\n * into the closest section title above these lines.\r\n */\r\nexport const groupLinesIntoSections = (lines: Lines) => {\r\n let sections: ResumeSectionToLines = {};\r\n let sectionName: string = PROFILE_SECTION;\r\n let sectionLines = [];\r\n for (let i = 0; i < lines.length; i++) {\r\n const line = lines[i];\r\n const text = line[0]?.text.trim();\r\n if (isSectionTitle(line, i)) {\r\n sections[sectionName] = [...sectionLines];\r\n sectionName = text;\r\n sectionLines = [];\r\n } else {\r\n sectionLines.push(line);\r\n }\r\n }\r\n if (sectionLines.length > 0) {\r\n sections[sectionName] = [...sectionLines];\r\n }\r\n return sections;\r\n};\r\n\r\nconst SECTION_TITLE_PRIMARY_KEYWORDS = [\r\n \"experience\",\r\n \"education\",\r\n \"project\",\r\n \"skill\",\r\n];\r\nconst SECTION_TITLE_SECONDARY_KEYWORDS = [\r\n \"job\",\r\n \"course\",\r\n \"extracurricular\",\r\n \"objective\",\r\n \"summary\", // LinkedIn generated resume has a summary section\r\n \"award\",\r\n \"honor\",\r\n \"project\",\r\n];\r\nconst SECTION_TITLE_KEYWORDS = [\r\n ...SECTION_TITLE_PRIMARY_KEYWORDS,\r\n ...SECTION_TITLE_SECONDARY_KEYWORDS,\r\n];\r\n\r\nconst isSectionTitle = (line: Line, lineNumber: number) => {\r\n const isFirstTwoLines = lineNumber < 2;\r\n const hasMoreThanOneItemInLine = line.length > 1;\r\n const hasNoItemInLine = line.length === 0;\r\n if (isFirstTwoLines || hasMoreThanOneItemInLine || hasNoItemInLine) {\r\n return false;\r\n }\r\n\r\n const textItem = line[0];\r\n\r\n // The main heuristic to determine a section title is to check if the text is double emphasized\r\n // to be both bold and all uppercase, which is generally true for a well formatted resume\r\n if (isBold(textItem) && hasLetterAndIsAllUpperCase(textItem)) {\r\n return true;\r\n }\r\n\r\n // The following is a fallback heuristic to detect section title if it includes a keyword match\r\n // (This heuristics is not well tested and may not work well)\r\n const text = textItem.text.trim();\r\n const textHasAtMost2Words =\r\n text.split(\" \").filter((s) => s !== \"&\").length <= 2;\r\n const startsWithCapitalLetter = /[A-Z]/.test(text.slice(0, 1));\r\n\r\n if (\r\n textHasAtMost2Words &&\r\n hasOnlyLettersSpacesAmpersands(textItem) &&\r\n startsWithCapitalLetter &&\r\n SECTION_TITLE_KEYWORDS.some((keyword) =>\r\n text.toLowerCase().includes(keyword)\r\n )\r\n ) {\r\n return true;\r\n }\r\n\r\n return false;\r\n};\r\n","import type { ResumeSectionToLines } from \"../../../../lib/parse-resume-from-pdf/types\";\r\n\r\n/**\r\n * Return section lines that contain any of the keywords.\r\n */\r\nexport const getSectionLinesByKeywords = (\r\n sections: ResumeSectionToLines,\r\n keywords: string[]\r\n) => {\r\n for (const sectionName in sections) {\r\n const hasKeyWord = keywords.some((keyword) =>\r\n sectionName.toLowerCase().includes(keyword)\r\n );\r\n if (hasKeyWord) {\r\n return sections[sectionName];\r\n }\r\n }\r\n return [];\r\n};\r\n","import type {\r\n TextItems,\r\n TextScores,\r\n FeatureSet,\r\n} from \"../../../../lib/parse-resume-from-pdf/types\";\r\n\r\nconst computeFeatureScores = (\r\n textItems: TextItems,\r\n featureSets: FeatureSet[]\r\n): TextScores => {\r\n const textScores = textItems.map((item) => ({\r\n text: item.text,\r\n score: 0,\r\n match: false,\r\n }));\r\n\r\n for (let i = 0; i < textItems.length; i++) {\r\n const textItem = textItems[i];\r\n\r\n for (const featureSet of featureSets) {\r\n const [hasFeature, score, returnMatchingText] = featureSet;\r\n const result = hasFeature(textItem);\r\n if (result) {\r\n let text = textItem.text;\r\n if (returnMatchingText && typeof result === \"object\") {\r\n text = result[0];\r\n }\r\n\r\n const textScore = textScores[i];\r\n if (textItem.text === text) {\r\n textScore.score += score;\r\n if (returnMatchingText) {\r\n textScore.match = true;\r\n }\r\n } else {\r\n textScores.push({ text, score, match: true });\r\n }\r\n }\r\n }\r\n }\r\n return textScores;\r\n};\r\n\r\n/**\r\n * Core util for the feature scoring system.\r\n *\r\n * It runs each text item through all feature sets and sums up the matching feature scores.\r\n * It then returns the text item with the highest computed feature score.\r\n */\r\nexport const getTextWithHighestFeatureScore = (\r\n textItems: TextItems,\r\n featureSets: FeatureSet[],\r\n returnEmptyStringIfHighestScoreIsNotPositive = true,\r\n returnConcatenatedStringForTextsWithSameHighestScore = false\r\n) => {\r\n const textScores = computeFeatureScores(textItems, featureSets);\r\n\r\n let textsWithHighestFeatureScore: string[] = [];\r\n let highestScore = -Infinity;\r\n for (const { text, score } of textScores) {\r\n if (score >= highestScore) {\r\n if (score > highestScore) {\r\n textsWithHighestFeatureScore = [];\r\n }\r\n textsWithHighestFeatureScore.push(text);\r\n highestScore = score;\r\n }\r\n }\r\n\r\n if (returnEmptyStringIfHighestScoreIsNotPositive && highestScore <= 0)\r\n return [\"\", textScores] as const;\r\n\r\n // Note: If textItems is an empty array, textsWithHighestFeatureScore[0] is undefined, so we default it to empty string\r\n const text = !returnConcatenatedStringForTextsWithSameHighestScore\r\n ? textsWithHighestFeatureScore[0] ?? \"\"\r\n : textsWithHighestFeatureScore.map((s) => s.trim()).join(\" \");\r\n\r\n return [text, textScores] as const;\r\n};\r\n","import type {\r\n ResumeSectionToLines,\r\n TextItem,\r\n FeatureSet,\r\n} from \"../../../lib/parse-resume-from-pdf/types\";\r\nimport { getSectionLinesByKeywords } from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/get-section-lines\";\r\nimport {\r\n isBold,\r\n hasNumber,\r\n hasComma,\r\n hasLetter,\r\n hasLetterAndIsAllUpperCase,\r\n} from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features\";\r\nimport { getTextWithHighestFeatureScore } from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/feature-scoring-system\";\r\n\r\n// Name\r\nexport const matchOnlyLetterSpaceOrPeriod = (item: TextItem) =>\r\n item.text.match(/^[a-zA-Z\\s\\.]+$/);\r\n\r\n// Email\r\n// Simple email regex: xxx@xxx.xxx (xxx = anything not space)\r\nexport const matchEmail = (item: TextItem) => item.text.match(/\\S+@\\S+\\.\\S+/);\r\nconst hasAt = (item: TextItem) => item.text.includes(\"@\");\r\n\r\n// Phone\r\n// Simple phone regex that matches (xxx)-xxx-xxxx where () and - are optional, - can also be space\r\nexport const matchPhone = (item: TextItem) =>\r\n item.text.match(/\\(?\\d{3}\\)?[\\s-]?\\d{3}[\\s-]?\\d{4}/);\r\nconst hasParenthesis = (item: TextItem) => /\\([0-9]+\\)/.test(item.text);\r\n\r\n// Location\r\n// Simple location regex that matches \"<City>, <ST>\"\r\nexport const matchCityAndState = (item: TextItem) =>\r\n item.text.match(/[A-Z][a-zA-Z\\s]+, [A-Z]{2}/);\r\n\r\n// Url\r\n// Simple url regex that matches \"xxx.xxx/xxx\" (xxx = anything not space)\r\nexport const matchUrl = (item: TextItem) => item.text.match(/\\S+\\.[a-z]+\\/\\S+/);\r\n// Match https://xxx.xxx where s is optional\r\nconst matchUrlHttpFallback = (item: TextItem) =>\r\n item.text.match(/https?:\\/\\/\\S+\\.\\S+/);\r\n// Match www.xxx.xxx\r\nconst matchUrlWwwFallback = (item: TextItem) =>\r\n item.text.match(/www\\.\\S+\\.\\S+/);\r\nconst hasSlash = (item: TextItem) => item.text.includes(\"/\");\r\n\r\n// Summary\r\nconst has4OrMoreWords = (item: TextItem) => item.text.split(\" \").length >= 4;\r\n\r\n/**\r\n * Unique Attribute\r\n * Name Bold or Has all uppercase letter\r\n * Email Has @\r\n * Phone Has ()\r\n * Location Has , (overlap with summary)\r\n * Url Has slash\r\n * Summary Has 4 or more words\r\n */\r\n\r\n/**\r\n * Name -> contains only letters/space/period, e.g. Leonardo W. DiCaprio\r\n * (it isn't common to include middle initial in resume)\r\n * -> is bolded or has all letters as uppercase\r\n */\r\nconst NAME_FEATURE_SETS: FeatureSet[] = [\r\n [matchOnlyLetterSpaceOrPeriod, 3, true],\r\n [isBold, 2],\r\n [hasLetterAndIsAllUpperCase, 2],\r\n // Match against other unique attributes\r\n [hasAt, -4], // Email\r\n [hasNumber, -4], // Phone\r\n [hasParenthesis, -4], // Phone\r\n [hasComma, -4], // Location\r\n [hasSlash, -4], // Url\r\n [has4OrMoreWords, -2], // Summary\r\n];\r\n\r\n// Email -> match email regex xxx@xxx.xxx\r\nconst EMAIL_FEATURE_SETS: FeatureSet[] = [\r\n [matchEmail, 4, true],\r\n [isBold, -1], // Name\r\n [hasLetterAndIsAllUpperCase, -1], // Name\r\n [hasParenthesis, -4], // Phone\r\n [hasComma, -4], // Location\r\n [hasSlash, -4], // Url\r\n [has4OrMoreWords, -4], // Summary\r\n];\r\n\r\n// Phone -> match phone regex (xxx)-xxx-xxxx\r\nconst PHONE_FEATURE_SETS: FeatureSet[] = [\r\n [matchPhone, 4, true],\r\n [hasLetter, -4], // Name, Email, Location, Url, Summary\r\n];\r\n\r\n// Location -> match location regex <City>, <ST>\r\nconst LOCATION_FEATURE_SETS: FeatureSet[] = [\r\n [matchCityAndState, 4, true],\r\n [isBold, -1], // Name\r\n [hasAt, -4], // Email\r\n [hasParenthesis, -3], // Phone\r\n [hasSlash, -4], // Url\r\n];\r\n\r\n// URL -> match url regex xxx.xxx/xxx\r\nconst URL_FEATURE_SETS: FeatureSet[] = [\r\n [matchUrl, 4, true],\r\n [matchUrlHttpFallback, 3, true],\r\n [matchUrlWwwFallback, 3, true],\r\n [isBold, -1], // Name\r\n [hasAt, -4], // Email\r\n [hasParenthesis, -3], // Phone\r\n [hasComma, -4], // Location\r\n [has4OrMoreWords, -4], // Summary\r\n];\r\n\r\n// Summary -> has 4 or more words\r\nconst SUMMARY_FEATURE_SETS: FeatureSet[] = [\r\n [has4OrMoreWords, 4],\r\n [isBold, -1], // Name\r\n [hasAt, -4], // Email\r\n [hasParenthesis, -3], // Phone\r\n [matchCityAndState, -4, false], // Location\r\n];\r\n\r\nexport const extractProfile = (sections: ResumeSectionToLines) => {\r\n const lines = sections.profile || [];\r\n const textItems = lines.flat();\r\n\r\n const [name, nameScores] = getTextWithHighestFeatureScore(\r\n textItems,\r\n NAME_FEATURE_SETS\r\n );\r\n const [email, emailScores] = getTextWithHighestFeatureScore(\r\n textItems,\r\n EMAIL_FEATURE_SETS\r\n );\r\n const [phone, phoneScores] = getTextWithHighestFeatureScore(\r\n textItems,\r\n PHONE_FEATURE_SETS\r\n );\r\n const [location, locationScores] = getTextWithHighestFeatureScore(\r\n textItems,\r\n LOCATION_FEATURE_SETS\r\n );\r\n const [url, urlScores] = getTextWithHighestFeatureScore(\r\n textItems,\r\n URL_FEATURE_SETS\r\n );\r\n const [summary, summaryScores] = getTextWithHighestFeatureScore(\r\n textItems,\r\n SUMMARY_FEATURE_SETS,\r\n undefined,\r\n true\r\n );\r\n\r\n const summaryLines = getSectionLinesByKeywords(sections, [\"summary\"]);\r\n const summarySection = summaryLines\r\n .flat()\r\n .map((textItem) => textItem.text)\r\n .join(\" \");\r\n const objectiveLines = getSectionLinesByKeywords(sections, [\"objective\"]);\r\n const objectiveSection = objectiveLines\r\n .flat()\r\n .map((textItem) => textItem.text)\r\n .join(\" \");\r\n\r\n return {\r\n profile: {\r\n name,\r\n email,\r\n phone,\r\n location,\r\n url,\r\n // Dedicated section takes higher precedence over profile summary\r\n summary: summarySection || objectiveSection || summary,\r\n },\r\n // For debugging\r\n profileScores: {\r\n name: nameScores,\r\n email: emailScores,\r\n phone: phoneScores,\r\n location: locationScores,\r\n url: urlScores,\r\n summary: summaryScores,\r\n },\r\n };\r\n};\r\n","import { BULLET_POINTS } from \"../../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points\";\r\nimport { isBold } from \"../../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features\";\r\nimport type { Lines, Line, Subsections } from \"../../../../lib/parse-resume-from-pdf/types\";\r\n\r\n/**\r\n * Divide lines into subsections based on difference in line gap or bold text.\r\n *\r\n * For profile section, we can directly pass all the text items to the feature\r\n * scoring systems. But for other sections, such as education and work experience,\r\n * we have to first divide the section into subsections since there can be multiple\r\n * schools or work experiences in the section. The feature scoring system then\r\n * process each subsection to retrieve each's resume attributes and append the results.\r\n */\r\nexport const divideSectionIntoSubsections = (lines: Lines): Subsections => {\r\n // The main heuristic to determine a subsection is to check if its vertical line gap\r\n // is larger than the typical line gap * 1.4\r\n const isLineNewSubsectionByLineGap =\r\n createIsLineNewSubsectionByLineGap(lines);\r\n\r\n let subsections = createSubsections(lines, isLineNewSubsectionByLineGap);\r\n\r\n // Fallback heuristic if the main heuristic doesn't apply to check if the text item is bolded\r\n if (subsections.length === 1) {\r\n const isLineNewSubsectionByBold = (line: Line, prevLine: Line) => {\r\n if (\r\n !isBold(prevLine[0]) &&\r\n isBold(line[0]) &&\r\n // Ignore bullet points that sometimes being marked as bolded\r\n !BULLET_POINTS.includes(line[0].text)\r\n ) {\r\n return true;\r\n }\r\n return false;\r\n };\r\n\r\n subsections = createSubsections(lines, isLineNewSubsectionByBold);\r\n }\r\n\r\n return subsections;\r\n};\r\n\r\ntype IsLineNewSubsection = (line: Line, prevLine: Line) => boolean;\r\n\r\nconst createIsLineNewSubsectionByLineGap = (\r\n lines: Lines\r\n): IsLineNewSubsection => {\r\n // Extract the common typical line gap\r\n const lineGapToCount: { [lineGap: number]: number } = {};\r\n const linesY = lines.map((line) => line[0].y);\r\n let lineGapWithMostCount: number = 0;\r\n let maxCount = 0;\r\n for (let i = 1; i < linesY.length; i++) {\r\n const lineGap = Math.round(linesY[i - 1] - linesY[i]);\r\n if (!lineGapToCount[lineGap]) lineGapToCount[lineGap] = 0;\r\n lineGapToCount[lineGap] += 1;\r\n if (lineGapToCount[lineGap] > maxCount) {\r\n lineGapWithMostCount = lineGap;\r\n maxCount = lineGapToCount[lineGap];\r\n }\r\n }\r\n // Use common line gap to set a sub section threshold\r\n const subsectionLineGapThreshold = lineGapWithMostCount * 1.4;\r\n\r\n const isLineNewSubsection = (line: Line, prevLine: Line) => {\r\n return Math.round(prevLine[0].y - line[0].y) > subsectionLineGapThreshold;\r\n };\r\n\r\n return isLineNewSubsection;\r\n};\r\n\r\nconst createSubsections = (\r\n lines: Lines,\r\n isLineNewSubsection: IsLineNewSubsection\r\n): Subsections => {\r\n const subsections: Subsections = [];\r\n let subsection: Lines = [];\r\n for (let i = 0; i < lines.length; i++) {\r\n const line = lines[i];\r\n if (i === 0) {\r\n subsection.push(line);\r\n continue;\r\n }\r\n if (isLineNewSubsection(line, lines[i - 1])) {\r\n subsections.push(subsection);\r\n subsection = [];\r\n }\r\n subsection.push(line);\r\n }\r\n if (subsection.length > 0) {\r\n subsections.push(subsection);\r\n }\r\n return subsections;\r\n};\r\n","import type {\r\n TextItem,\r\n FeatureSet,\r\n ResumeSectionToLines,\r\n} from \"../../../lib/parse-resume-from-pdf/types\";\r\nimport type { ResumeEducation } from \"../../../lib/redux/types\";\r\nimport { getSectionLinesByKeywords } from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/get-section-lines\";\r\nimport { divideSectionIntoSubsections } from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/subsections\";\r\nimport {\r\n DATE_FEATURE_SETS,\r\n hasComma,\r\n hasLetter,\r\n hasNumber,\r\n} from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features\";\r\nimport { getTextWithHighestFeatureScore } from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/feature-scoring-system\";\r\nimport {\r\n getBulletPointsFromLines,\r\n getDescriptionsLineIdx,\r\n} from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points\";\r\n\r\n/**\r\n * Unique Attribute\r\n * School Has school\r\n * Degree Has degree\r\n * GPA Has number\r\n */\r\n\r\n// prettier-ignore\r\nconst SCHOOLS = ['College', 'University', 'Institute', 'School', 'Academy', 'BASIS', 'Magnet']\r\nconst hasSchool = (item: TextItem) =>\r\n SCHOOLS.some((school) => item.text.includes(school));\r\n// prettier-ignore\r\nconst DEGREES = [\"Associate\", \"Bachelor\", \"Master\", \"PhD\", \"Ph.\"];\r\nconst hasDegree = (item: TextItem) =>\r\n DEGREES.some((degree) => item.text.includes(degree)) ||\r\n /[ABM][A-Z\\.]/.test(item.text); // Match AA, B.S., MBA, etc.\r\nconst matchGPA = (item: TextItem) => item.text.match(/[0-4]\\.\\d{1,2}/);\r\nconst matchGrade = (item: TextItem) => {\r\n const grade = parseFloat(item.text);\r\n if (Number.isFinite(grade) && grade <= 110) {\r\n return [String(grade)] as RegExpMatchArray;\r\n }\r\n return null;\r\n};\r\n\r\nconst SCHOOL_FEATURE_SETS: FeatureSet[] = [\r\n [hasSchool, 4],\r\n [hasDegree, -4],\r\n [hasNumber, -4],\r\n];\r\n\r\nconst DEGREE_FEATURE_SETS: FeatureSet[] = [\r\n [hasDegree, 4],\r\n [hasSchool, -4],\r\n [hasNumber, -3],\r\n];\r\n\r\nconst GPA_FEATURE_SETS: FeatureSet[] = [\r\n [matchGPA, 4, true],\r\n [matchGrade, 3, true],\r\n [hasComma, -3],\r\n [hasLetter, -4],\r\n];\r\n\r\nexport const extractEducation = (sections: ResumeSectionToLines) => {\r\n const educations: ResumeEducation[] = [];\r\n const educationsScores = [];\r\n const lines = getSectionLinesByKeywords(sections, [\"education\"]);\r\n const subsections = divideSectionIntoSubsections(lines);\r\n for (const subsectionLines of subsections) {\r\n const textItems = subsectionLines.flat();\r\n const [school, schoolScores] = getTextWithHighestFeatureScore(\r\n textItems,\r\n SCHOOL_FEATURE_SETS\r\n );\r\n const [degree, degreeScores] = getTextWithHighestFeatureScore(\r\n textItems,\r\n DEGREE_FEATURE_SETS\r\n );\r\n const [gpa, gpaScores] = getTextWithHighestFeatureScore(\r\n textItems,\r\n GPA_FEATURE_SETS\r\n );\r\n const [date, dateScores] = getTextWithHighestFeatureScore(\r\n textItems,\r\n DATE_FEATURE_SETS\r\n );\r\n\r\n let descriptions: string[] = [];\r\n const descriptionsLineIdx = getDescriptionsLineIdx(subsectionLines);\r\n if (descriptionsLineIdx !== undefined) {\r\n const descriptionsLines = subsectionLines.slice(descriptionsLineIdx);\r\n descriptions = getBulletPointsFromLines(descriptionsLines);\r\n }\r\n\r\n educations.push({ school, degree, gpa, date, descriptions });\r\n educationsScores.push({\r\n schoolScores,\r\n degreeScores,\r\n gpaScores,\r\n dateScores,\r\n });\r\n }\r\n\r\n if (educations.length !== 0) {\r\n const coursesLines = getSectionLinesByKeywords(sections, [\"course\"]);\r\n if (coursesLines.length !== 0) {\r\n educations[0].descriptions.push(\r\n \"Courses: \" +\r\n coursesLines\r\n .flat()\r\n .map((item) => item.text)\r\n .join(\" \")\r\n );\r\n }\r\n }\r\n\r\n return {\r\n educations,\r\n educationsScores,\r\n };\r\n};\r\n","import type { ResumeWorkExperience } from \"../../../lib/redux/types\";\r\nimport type {\r\n TextItem,\r\n FeatureSet,\r\n ResumeSectionToLines,\r\n} from \"../../../lib/parse-resume-from-pdf/types\";\r\nimport { getSectionLinesByKeywords } from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/get-section-lines\";\r\nimport {\r\n DATE_FEATURE_SETS,\r\n hasNumber,\r\n getHasText,\r\n isBold,\r\n} from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features\";\r\nimport { divideSectionIntoSubsections } from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/subsections\";\r\nimport { getTextWithHighestFeatureScore } from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/feature-scoring-system\";\r\nimport {\r\n getBulletPointsFromLines,\r\n getDescriptionsLineIdx,\r\n} from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points\";\r\n\r\n// prettier-ignore\r\nconst WORK_EXPERIENCE_KEYWORDS_LOWERCASE = ['work', 'experience', 'employment', 'history', 'job'];\r\n// prettier-ignore\r\nconst JOB_TITLES = ['Accountant', 'Administrator', 'Advisor', 'Agent', 'Analyst', 'Apprentice', 'Architect', 'Assistant', 'Associate', 'Auditor', 'Bartender', 'Biologist', 'Bookkeeper', 'Buyer', 'Carpenter', 'Cashier', 'CEO', 'Clerk', 'Co-op', 'Co-Founder', 'Consultant', 'Coordinator', 'CTO', 'Developer', 'Designer', 'Director', 'Driver', 'Editor', 'Electrician', 'Engineer', 'Extern', 'Founder', 'Freelancer', 'Head', 'Intern', 'Janitor', 'Journalist', 'Laborer', 'Lawyer', 'Lead', 'Manager', 'Mechanic', 'Member', 'Nurse', 'Officer', 'Operator', 'Operation', 'Photographer', 'President', 'Producer', 'Recruiter', 'Representative', 'Researcher', 'Sales', 'Server', 'Scientist', 'Specialist', 'Supervisor', 'Teacher', 'Technician', 'Trader', 'Trainee', 'Treasurer', 'Tutor', 'Vice', 'VP', 'Volunteer', 'Webmaster', 'Worker'];\r\n\r\nconst hasJobTitle = (item: TextItem) =>\r\n JOB_TITLES.some((jobTitle) =>\r\n item.text.split(/\\s/).some((word) => word === jobTitle)\r\n );\r\nconst hasMoreThan5Words = (item: TextItem) => item.text.split(/\\s/).length > 5;\r\nconst JOB_TITLE_FEATURE_SET: FeatureSet[] = [\r\n [hasJobTitle, 4],\r\n [hasNumber, -4],\r\n [hasMoreThan5Words, -2],\r\n];\r\n\r\nexport const extractWorkExperience = (sections: ResumeSectionToLines) => {\r\n const workExperiences: ResumeWorkExperience[] = [];\r\n const workExperiencesScores = [];\r\n const lines = getSectionLinesByKeywords(\r\n sections,\r\n WORK_EXPERIENCE_KEYWORDS_LOWERCASE\r\n );\r\n const subsections = divideSectionIntoSubsections(lines);\r\n\r\n for (const subsectionLines of subsections) {\r\n const descriptionsLineIdx = getDescriptionsLineIdx(subsectionLines) ?? 2;\r\n\r\n const subsectionInfoTextItems = subsectionLines\r\n .slice(0, descriptionsLineIdx)\r\n .flat();\r\n const [date, dateScores] = getTextWithHighestFeatureScore(\r\n subsectionInfoTextItems,\r\n DATE_FEATURE_SETS\r\n );\r\n const [jobTitle, jobTitleScores] = getTextWithHighestFeatureScore(\r\n subsectionInfoTextItems,\r\n JOB_TITLE_FEATURE_SET\r\n );\r\n const COMPANY_FEATURE_SET: FeatureSet[] = [\r\n [isBold, 2],\r\n [getHasText(date), -4],\r\n [getHasText(jobTitle), -4],\r\n ];\r\n const [company, companyScores] = getTextWithHighestFeatureScore(\r\n subsectionInfoTextItems,\r\n COMPANY_FEATURE_SET,\r\n false\r\n );\r\n\r\n const subsectionDescriptionsLines =\r\n subsectionLines.slice(descriptionsLineIdx);\r\n const descriptions = getBulletPointsFromLines(subsectionDescriptionsLines);\r\n\r\n workExperiences.push({ company, jobTitle, date, descriptions });\r\n workExperiencesScores.push({\r\n companyScores,\r\n jobTitleScores,\r\n dateScores,\r\n });\r\n }\r\n return { workExperiences, workExperiencesScores };\r\n};\r\n","import type { ResumeProject } from \"../../../lib/redux/types\";\r\nimport type {\r\n FeatureSet,\r\n ResumeSectionToLines,\r\n} from \"../../../lib/parse-resume-from-pdf/types\";\r\nimport { getSectionLinesByKeywords } from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/get-section-lines\";\r\nimport {\r\n DATE_FEATURE_SETS,\r\n getHasText,\r\n isBold,\r\n} from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features\";\r\nimport { divideSectionIntoSubsections } from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/subsections\";\r\nimport { getTextWithHighestFeatureScore } from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/feature-scoring-system\";\r\nimport {\r\n getBulletPointsFromLines,\r\n getDescriptionsLineIdx,\r\n} from \"../../../lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points\";\r\n\r\nexport const extractProject = (sections: ResumeSectionToLines) => {\r\n const projects: ResumeProject[] = [];\r\n const projectsScores = [];\r\n const lines = getSectionLinesByKeywords(sections, [\"project\"]);\r\n const subsections = divideSectionIntoSubsections(lines);\r\n\r\n for (const subsectionLines of subsections) {\r\n const descriptionsLineIdx = getDescriptionsLineIdx(subsectionLines) ?? 1;\r\n\r\n const subsectionInfoTextItems = subsectionLines\r\n .slice(0, descriptionsLineIdx)\r\n .flat();\r\n const [date, dateScores] = getTextWithHighestFeatureScore(\r\n subsectionInfoTextItems,\r\n DATE_FEATURE_SETS\r\n );\r\n const PROJECT_FEATURE_SET: FeatureSet[] = [\r\n [isBold, 2],\r\n [getHasText(date), -4],\r\n ];\r\n const [project, projectScores] = getTextWithHighestFeatureScore(\r\n subsectionInfoTextItems,\r\n PROJECT_FEATURE_SET,\r\n false\r\n );\r\n\r\n const descriptionsLines = subsectionLines.slice(descriptionsLineIdx);\r\n const descriptions = getBulletPointsFromLines(descriptionsLines);\r\n\r\n projects.push({ project, date, descriptions });\r\n projectsScores.push({\r\n projectScores,\r\n dateScores,\r\n });\r\n }\r\n return { projects, projectsScores };\r\n};\r\n","/**\r\n * Server side object deep clone util using JSON serialization.\r\n * Not efficient for large objects but good enough for most use cases.\r\n *\r\n * Client side can simply use structuredClone.\r\n */\r\nexport const deepClone = <T extends { [key: string]: any }>(object: T) =>\r\n JSON.parse(JSON.stringify(object)) as T;\r\n","import { createSlice, type PayloadAction } from \"@reduxjs/toolkit\";\r\nimport type { RootState } from \"../../lib/redux/store\";\r\nimport type {\r\n FeaturedSkill,\r\n Resume,\r\n ResumeEducation,\r\n ResumeProfile,\r\n ResumeProject,\r\n ResumeSkills,\r\n ResumeWorkExperience,\r\n} from \"../../lib/redux/types\";\r\nimport type { ShowForm } from \"../../lib/redux/settingsSlice\";\r\n\r\nexport const initialProfile: ResumeProfile = {\r\n name: \"\",\r\n summary: \"\",\r\n email: \"\",\r\n phone: \"\",\r\n location: \"\",\r\n url: \"\",\r\n};\r\n\r\nexport const initialWorkExperience: ResumeWorkExperience = {\r\n company: \"\",\r\n jobTitle: \"\",\r\n date: \"\",\r\n descriptions: [],\r\n};\r\n\r\nexport const initialEducation: ResumeEducation = {\r\n school: \"\",\r\n degree: \"\",\r\n gpa: \"\",\r\n date: \"\",\r\n descriptions: [],\r\n};\r\n\r\nexport const initialProject: ResumeProject = {\r\n project: \"\",\r\n date: \"\",\r\n descriptions: [],\r\n};\r\n\r\nexport const initialFeaturedSkill: FeaturedSkill = { skill: \"\", rating: 4 };\r\nexport const initialFeaturedSkills: FeaturedSkill[] = Array(6).fill({\r\n ...initialFeaturedSkill,\r\n});\r\nexport const initialSkills: ResumeSkills = {\r\n featuredSkills: initialFeaturedSkills,\r\n descriptions: [],\r\n};\r\n\r\nexport const initialCustom = {\r\n descriptions: [],\r\n};\r\n\r\nexport const initialResumeState: Resume = {\r\n profile: initialProfile,\r\n workExperiences: [initialWorkExperience],\r\n educations: [initialEducation],\r\n projects: [initialProject],\r\n skills: initialSkills,\r\n custom: initialCustom,\r\n};\r\n\r\n// Keep the field & value type in sync with CreateHandleChangeArgsWithDescriptions (components\\ResumeForm\\types.ts)\r\nexport type CreateChangeActionWithDescriptions<T> = {\r\n idx: number;\r\n} & (\r\n | {\r\n field: Exclude<keyof T, \"descriptions\">;\r\n value: string;\r\n }\r\n | { field: \"descriptions\"; value: string[] }\r\n);\r\n\r\nexport const resumeSlice = createSlice({\r\n name: \"resume\",\r\n initialState: initialResumeState,\r\n reducers: {\r\n changeProfile: (\r\n draft,\r\n action: PayloadAction<{ field: keyof ResumeProfile; value: string }>\r\n ) => {\r\n const { field, value } = action.payload;\r\n draft.profile[field] = value;\r\n },\r\n changeWorkExperiences: (\r\n draft,\r\n action: PayloadAction<\r\n CreateChangeActionWithDescriptions<ResumeWorkExperience>\r\n >\r\n ) => {\r\n const { idx, field, value } = action.payload;\r\n const workExperience = draft.workExperiences[idx];\r\n workExperience[field] = value as any;\r\n },\r\n changeEducations: (\r\n draft,\r\n action: PayloadAction<CreateChangeActionWithDescriptions<ResumeEducation>>\r\n ) => {\r\n const { idx, field, value } = action.payload;\r\n const education = draft.educations[idx];\r\n education[field] = value as any;\r\n },\r\n changeProjects: (\r\n draft,\r\n action: PayloadAction<CreateChangeActionWithDescriptions<ResumeProject>>\r\n ) => {\r\n const { idx, field, value } = action.payload;\r\n const project = draft.projects[idx];\r\n project[field] = value as any;\r\n },\r\n changeSkills: (\r\n draft,\r\n action: PayloadAction<\r\n | { field: \"descriptions\"; value: string[] }\r\n | {\r\n field: \"featuredSkills\";\r\n idx: number;\r\n skill: string;\r\n rating: number;\r\n }\r\n >\r\n ) => {\r\n const { field } = action.payload;\r\n if (field === \"descriptions\") {\r\n const { value } = action.payload;\r\n draft.skills.descriptions = value;\r\n } else {\r\n const { idx, skill, rating } = action.payload;\r\n const featuredSkill = draft.skills.featuredSkills[idx];\r\n featuredSkill.skill = skill;\r\n featuredSkill.rating = rating;\r\n }\r\n },\r\n changeCustom: (\r\n draft,\r\n action: PayloadAction<{ field: \"descriptions\"; value: string[] }>\r\n ) => {\r\n const { value } = action.payload;\r\n draft.custom.descriptions = value;\r\n },\r\n addSectionInForm: (draft, action: PayloadAction<{ form: ShowForm }>) => {\r\n const { form } = action.payload;\r\n switch (form) {\r\n case \"workExperiences\": {\r\n draft.workExperiences.push(structuredClone(initialWorkExperience));\r\n return draft;\r\n }\r\n case \"educations\": {\r\n draft.educations.push(structuredClone(initialEducation));\r\n return draft;\r\n }\r\n case \"projects\": {\r\n draft.projects.push(structuredClone(initialProject));\r\n return draft;\r\n }\r\n }\r\n },\r\n moveSectionInForm: (\r\n draft,\r\n action: PayloadAction<{\r\n form: ShowForm;\r\n idx: number;\r\n direction: \"up\" | \"down\";\r\n }>\r\n ) => {\r\n const { form, idx, direction } = action.payload;\r\n if (form !== \"skills\" && form !== \"custom\") {\r\n if (\r\n (idx === 0 && direction === \"up\") ||\r\n (idx === draft[form].length - 1 && direction === \"down\")\r\n ) {\r\n return draft;\r\n }\r\n\r\n const section = draft[form][idx];\r\n if (direction === \"up\") {\r\n draft[form][idx] = draft[form][idx - 1];\r\n draft[form][idx - 1] = section;\r\n } else {\r\n draft[form][idx] = draft[form][idx + 1];\r\n draft[form][idx + 1] = section;\r\n }\r\n }\r\n },\r\n deleteSectionInFormByIdx: (\r\n draft,\r\n action: PayloadAction<{ form: ShowForm; idx: number }>\r\n ) => {\r\n const { form, idx } = action.payload;\r\n if (form !== \"skills\" && form !== \"custom\") {\r\n draft[form].splice(idx, 1);\r\n }\r\n },\r\n setResume: (draft, action: PayloadAction<Resume>) => {\r\n return action.payload;\r\n },\r\n },\r\n});\r\n\r\nexport const {\r\n changeProfile,\r\n changeWorkExperiences,\r\n changeEducations,\r\n changeProjects,\r\n changeSkills,\r\n changeCustom,\r\n addSectionInForm,\r\n moveSectionInForm,\r\n deleteSectionInFormByIdx,\r\n setResume,\r\n} = resumeSlice.actions;\r\n\r\nexport const selectResume = (state: RootState) => state.resume;\r\nexport const selectProfile = (state: RootState) => state.resume.profile;\r\nexport const selectWorkExperiences = (state: RootState) =>\r\n state.resume.workExperiences;\r\nexport const selectEducations = (state: RootState) => state.resume.educations;\r\nexport const selectProjects = (state: RootState) => state.resume.projects;\r\nexport const selectSkills = (state: RootState) => state.resume.skills;\r\n