WIP
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
name: Build Playwright Runner Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/Dockerfile'
|
||||
- '.github/workflows/build-playwright-image.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/jobspeed-playwright-runner
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix={{branch}}-
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: scripts/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -56,29 +56,22 @@ jobs:
|
||||
demo-e2e-tests:
|
||||
name: Demo App E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/${{ github.repository_owner }}/jobspeed-playwright-runner:latest
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.18.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: cd job-application-demo && npx playwright install --with-deps chromium firefox
|
||||
working-directory: job-application-demo
|
||||
|
||||
- name: Run E2E tests
|
||||
run: cd job-application-demo && pnpm test:e2e
|
||||
run: pnpm test:e2e
|
||||
working-directory: job-application-demo
|
||||
|
||||
- name: Upload test results
|
||||
if: failure()
|
||||
|
||||
@@ -10,3 +10,5 @@ coverage
|
||||
*~
|
||||
web-ext-artifacts
|
||||
.claude/
|
||||
.pnpm*
|
||||
.turbo
|
||||
|
||||
@@ -17,6 +17,9 @@ A Firefox browser extension that helps you auto-fill job applications using your
|
||||
- **Vitest** - Unit testing
|
||||
- **ESLint** + **Prettier** - Code quality and formatting
|
||||
- **Native Browser API** - Firefox WebExtensions API
|
||||
- **PDF.js** - Client-side PDF parsing
|
||||
- **Mammoth** - Client-side DOCX parsing
|
||||
- **Turborepo** - Monorepo management
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -33,7 +36,7 @@ A Firefox browser extension that helps you auto-fill job applications using your
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. Start development mode:
|
||||
2. Start extension development mode:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
@@ -57,6 +60,7 @@ The production-ready extension will be in the `dist` folder.
|
||||
|
||||
### Available Scripts
|
||||
|
||||
**Extension Scripts:**
|
||||
- `pnpm dev` - Build and watch for changes
|
||||
- `pnpm build` - Production build
|
||||
- `pnpm test` - Run tests
|
||||
@@ -66,6 +70,17 @@ The production-ready extension will be in the `dist` folder.
|
||||
- `pnpm format` - Format code with Prettier
|
||||
- `pnpm format:check` - Check code formatting
|
||||
|
||||
**Turborepo Scripts (run tasks across all packages):**
|
||||
- `pnpm turbo:build` - Build all packages in parallel with caching
|
||||
- `pnpm turbo:test` - Run tests across all packages
|
||||
- `pnpm turbo:lint` - Lint all packages
|
||||
- `pnpm turbo:format` - Format all packages
|
||||
|
||||
**Advanced Turbo Commands:**
|
||||
- `turbo run build --filter=job-application-demo` - Build only the demo app
|
||||
- `turbo run test --force` - Skip cache and run all tests
|
||||
- `turbo run build --dry` - See what would run without executing
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
@@ -76,16 +91,33 @@ src/
|
||||
├── popup.ts # Popup UI logic
|
||||
├── popup.html # Popup HTML
|
||||
├── popup.css # Popup styles
|
||||
├── upload.tsx # Resume upload UI (React)
|
||||
├── upload.html # Upload page HTML
|
||||
├── types/
|
||||
│ ├── index.ts # Type definitions
|
||||
│ └── browser.d.ts # Browser API types
|
||||
└── services/
|
||||
├── formDetector.ts # Form field detection logic
|
||||
└── fieldMatcher.ts # Resume-to-field matching logic
|
||||
├── services/
|
||||
│ ├── formDetector.ts # Form field detection logic
|
||||
│ └── fieldMatcher.ts # Resume-to-field matching logic
|
||||
└── utils/
|
||||
└── resumeParser.ts # Client-side resume parsing (PDF, DOCX)
|
||||
|
||||
job-application-demo/ # Sample job application form (separate app)
|
||||
```
|
||||
|
||||
### Client-Side Resume Parsing
|
||||
|
||||
The extension includes a built-in client-side resume parser that handles file parsing directly in the browser:
|
||||
|
||||
- Parses PDF files using PDF.js (Mozilla's PDF library)
|
||||
- Parses DOCX files using Mammoth
|
||||
- Extracts structured resume data (personal info, work experience, education, skills)
|
||||
- All parsing happens locally in your browser
|
||||
- No external servers or network requests required
|
||||
- Works completely offline
|
||||
|
||||
The parser is located in `src/utils/resumeParser.ts` and is automatically used when you upload a resume through the extension.
|
||||
|
||||
### Demo Application
|
||||
|
||||
The `job-application-demo` folder contains a standalone sample job application form built with Vite, React, Material UI, and TypeScript. This demo app:
|
||||
@@ -111,7 +143,8 @@ See [job-application-demo/README.md](job-application-demo/README.md) for more de
|
||||
1. **Upload Your Resume**:
|
||||
- Click the JobSpeed extension icon
|
||||
- Click "Upload Resume"
|
||||
- Select your resume file (PDF, TXT, DOC, DOCX)
|
||||
- Select your resume file (PDF or DOCX)
|
||||
- The extension will parse the file directly in your browser
|
||||
|
||||
2. **Auto-fill a Job Application**:
|
||||
- Navigate to a job application page
|
||||
@@ -124,12 +157,18 @@ See [job-application-demo/README.md](job-application-demo/README.md) for more de
|
||||
|
||||
### Resume Parsing
|
||||
|
||||
The extension extracts key information from your resume:
|
||||
- Personal information (name, email, phone, address)
|
||||
- Work experience
|
||||
- Education
|
||||
- Skills
|
||||
- Professional summary
|
||||
The extension parses your resume files directly in the browser and extracts key information:
|
||||
- **Personal information** (name, email, phone, address)
|
||||
- **Work experience**
|
||||
- **Education**
|
||||
- **Skills**
|
||||
- **Professional summary**
|
||||
|
||||
Supported formats:
|
||||
- **PDF** - Parsed using PDF.js (Mozilla's library)
|
||||
- **DOCX** - Parsed using Mammoth
|
||||
|
||||
The parsed data is stored locally in your browser storage.
|
||||
|
||||
### Smart Field Matching
|
||||
|
||||
@@ -141,8 +180,11 @@ The field matcher uses intelligent algorithms to match resume data with form fie
|
||||
|
||||
### Privacy & Security
|
||||
|
||||
- All data is stored locally in your browser
|
||||
- No data is sent to external servers
|
||||
- All resume parsing happens **client-side** in your browser
|
||||
- All parsed resume data is stored locally in your browser storage
|
||||
- No data is sent to external servers or third parties
|
||||
- Resume files are processed entirely in memory within the extension
|
||||
- Works completely offline
|
||||
- You have full control over your resume data
|
||||
|
||||
## Contributing
|
||||
|
||||
+12
-3
@@ -5,14 +5,21 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch --mode development",
|
||||
"build": "tsc && vite build && pnpm copy:icons",
|
||||
"build": "tsc && vite build && pnpm copy:icons && pnpm build:upload",
|
||||
"build:upload": "node scripts/build-upload.mjs",
|
||||
"copy:icons": "mkdir -p dist/icons && cp src/icons/*.svg dist/icons/",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"lint": "ESLINT_USE_FLAT_CONFIG=false eslint . --ext .ts,.tsx",
|
||||
"lint:fix": "ESLINT_USE_FLAT_CONFIG=false eslint . --ext .ts,.tsx --fix",
|
||||
"format": "prettier --write \"{src,job-application-demo/src}/**/*.{ts,tsx,json,css}\"",
|
||||
"format:check": "prettier --check \"{src,job-application-demo/src}/**/*.{ts,tsx,json,css}\""
|
||||
"format:check": "prettier --check \"{src,job-application-demo/src}/**/*.{ts,tsx,json,css}\"",
|
||||
"turbo:build": "turbo run build",
|
||||
"turbo:dev": "turbo run dev",
|
||||
"turbo:test": "turbo run test",
|
||||
"turbo:lint": "turbo run lint",
|
||||
"turbo:format": "turbo run format",
|
||||
"turbo:format:check": "turbo run format:check"
|
||||
},
|
||||
"keywords": [
|
||||
"firefox",
|
||||
@@ -37,6 +44,7 @@
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"jsdom": "^27.0.1",
|
||||
"prettier": "^3.3.2",
|
||||
"turbo": "^2.5.8",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^7.1.10",
|
||||
"vite-plugin-web-extension": "^4.1.6",
|
||||
@@ -47,8 +55,9 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.4",
|
||||
"@mui/material": "^7.3.4",
|
||||
"mammoth": "^1.11.0",
|
||||
"pdfjs-dist": "^5.4.296",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+1085
-21
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
# Node modules
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
*.vsix
|
||||
|
||||
# Dependencies
|
||||
.pnpm-store/
|
||||
|
||||
# Test outputs
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,35 @@
|
||||
# Ubuntu 24.04 LTS (Noble Numbat) - pinned version
|
||||
FROM ubuntu:24.04
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_VERSION=22
|
||||
ENV PNPM_VERSION=10.18.1
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
ca-certificates \
|
||||
gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js 22
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Enable corepack and setup pnpm
|
||||
RUN corepack enable \
|
||||
&& corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||
|
||||
# Install Playwright dependencies
|
||||
# This installs all system dependencies needed for Chromium and Firefox
|
||||
RUN npx playwright@1.56.1 install --with-deps chromium firefox
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /workspace
|
||||
|
||||
# Default command
|
||||
CMD ["/bin/bash"]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Playwright Runner Docker Image
|
||||
|
||||
This directory contains a Dockerfile for running Playwright e2e tests in CI/CD.
|
||||
|
||||
## Overview
|
||||
|
||||
The Docker image is based on **Ubuntu 24.04 LTS (Noble Numbat)** and includes:
|
||||
- Node.js 22
|
||||
- pnpm 10.18.1 (via corepack)
|
||||
- Playwright 1.56.1 with Chromium and Firefox browsers and all system dependencies
|
||||
|
||||
## Local Testing
|
||||
|
||||
Build the image locally:
|
||||
|
||||
```bash
|
||||
docker build -t jobspeed-playwright-runner:latest -f scripts/Dockerfile .
|
||||
```
|
||||
|
||||
Run e2e tests in the container:
|
||||
|
||||
```bash
|
||||
docker run --rm -e CI=true \
|
||||
-v $(pwd):/workspace \
|
||||
-w /workspace/job-application-demo \
|
||||
jobspeed-playwright-runner:latest \
|
||||
bash -c "pnpm install --frozen-lockfile && pnpm test:e2e"
|
||||
```
|
||||
|
||||
## CI/CD Usage
|
||||
|
||||
The image is automatically built and pushed to GitHub Container Registry (ghcr.io) when:
|
||||
- Changes are pushed to the `main` branch that affect `scripts/Dockerfile`
|
||||
- The workflow is manually triggered
|
||||
|
||||
The `status-checks.yml` workflow uses this image for running e2e tests, eliminating the need for:
|
||||
- Manual pnpm setup
|
||||
- Node.js setup
|
||||
- Playwright browser installation
|
||||
|
||||
## Image Location
|
||||
|
||||
The image is published to: `ghcr.io/<repository-owner>/jobspeed-playwright-runner:latest`
|
||||
@@ -0,0 +1,35 @@
|
||||
import { build } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function buildUploadPage() {
|
||||
try {
|
||||
await build({
|
||||
root: path.resolve(__dirname, '../src'),
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, '../dist'),
|
||||
emptyOutDir: false, // Don't empty the dir - other files are already there
|
||||
rollupOptions: {
|
||||
input: {
|
||||
upload: path.resolve(__dirname, '../src/upload.html'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].js',
|
||||
chunkFileNames: '[name]-[hash].js',
|
||||
assetFileNames: '[name].[ext]',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [react()],
|
||||
});
|
||||
console.log('✓ Upload page built successfully');
|
||||
} catch (error) {
|
||||
console.error('✗ Error building upload page:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
buildUploadPage();
|
||||
@@ -26,5 +26,11 @@
|
||||
"js": ["content.ts"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["upload.html"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Splits raw resume text into distinct logical sections based on common headers.
|
||||
* The part before the first recognized header is stored as 'header'.
|
||||
*/
|
||||
export function sectionizeResume(rawText: string): Record<string, string[]> {
|
||||
const lines = rawText.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
||||
|
||||
// Use a map of normalized keywords to the internal section key
|
||||
const keywordMap: Record<string, string> = {
|
||||
'summary': 'summary',
|
||||
'objective': 'summary',
|
||||
'professional summary': 'summary',
|
||||
'profile': 'summary',
|
||||
|
||||
'experience': 'workExperience',
|
||||
'professional experience': 'workExperience',
|
||||
'work history': 'workExperience',
|
||||
'employment': 'workExperience',
|
||||
'relevant experience': 'workExperience',
|
||||
|
||||
'education': 'education',
|
||||
'academic background': 'education',
|
||||
|
||||
'skills': 'skills',
|
||||
'technical skills': 'skills',
|
||||
'proficiencies': 'skills',
|
||||
'expertise': 'skills',
|
||||
};
|
||||
|
||||
const sections: Record<string, string[]> = {
|
||||
header: [],
|
||||
summary: [],
|
||||
workExperience: [],
|
||||
education: [],
|
||||
skills: [],
|
||||
};
|
||||
|
||||
let currentSection = 'header';
|
||||
|
||||
for (const line of lines) {
|
||||
const normalizedLine = line.toLowerCase();
|
||||
let foundSection = false;
|
||||
|
||||
for (const [keyword, sectionKey] of Object.entries(keywordMap)) {
|
||||
// Check if the line is a major section header
|
||||
if (normalizedLine.startsWith(keyword) && line.length < 50) {
|
||||
currentSection = sectionKey;
|
||||
foundSection = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// I'm not sure if this is the best approach. Re-evalutate in morning/soon
|
||||
if (!foundSection) {
|
||||
const section = sections[currentSection];
|
||||
if (section) {
|
||||
section.push(line);
|
||||
} else {
|
||||
sections[currentSection] = [line];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
+5
-61
@@ -64,30 +64,11 @@ const PopupApp: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleUploadResume = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.pdf,.txt,.doc,.docx';
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsedData = parseResume(text);
|
||||
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'SAVE_RESUME_DATA',
|
||||
data: parsedData,
|
||||
});
|
||||
|
||||
await loadResumeStatus();
|
||||
alert('Resume uploaded successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error uploading resume:', error);
|
||||
alert('Error uploading resume. Please try again.');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
// Open the upload page in a new tab to avoid popup closing in Firefox
|
||||
void browser.tabs.create({
|
||||
url: browser.runtime.getURL('upload.html'),
|
||||
});
|
||||
window.close();
|
||||
};
|
||||
|
||||
const handleClearResume = async () => {
|
||||
@@ -133,43 +114,6 @@ const PopupApp: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const parseResume = (text: string): ResumeData => {
|
||||
const lines = text.split('\n').filter((line) => line.trim());
|
||||
|
||||
return {
|
||||
personalInfo: {
|
||||
fullName:
|
||||
lines
|
||||
.find((line) => line.match(/^[A-Z][a-z]+ [A-Z][a-z]+/))
|
||||
?.trim() || '',
|
||||
email:
|
||||
lines
|
||||
.find((line) =>
|
||||
line.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/)
|
||||
)
|
||||
?.trim() || '',
|
||||
phone:
|
||||
lines
|
||||
.find((line) =>
|
||||
line.match(/(\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/)
|
||||
)
|
||||
?.trim() || '',
|
||||
address:
|
||||
lines
|
||||
.find((line) => line.match(/\d+\s+\w+\s+(St|Ave|Rd|Blvd|Ln|Dr)/i))
|
||||
?.trim() || '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
country: '',
|
||||
},
|
||||
workExperience: [],
|
||||
education: [],
|
||||
skills: [],
|
||||
summary: '',
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>JobSpeed - Upload Resume</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="upload.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
import React, { useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import {
|
||||
ThemeProvider,
|
||||
createTheme,
|
||||
CssBaseline,
|
||||
Box,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Paper,
|
||||
Button,
|
||||
Alert,
|
||||
Stack,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Upload as UploadIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Close as CloseIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { ResumeData } from './types';
|
||||
import { parseResumeFile } from './utils/resumeParser';
|
||||
|
||||
// Create a custom theme for the extension
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#667eea',
|
||||
},
|
||||
secondary: {
|
||||
main: '#764ba2',
|
||||
},
|
||||
background: {
|
||||
default: '#f5f5f5',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif',
|
||||
},
|
||||
});
|
||||
|
||||
const UploadApp: React.FC = () => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadSuccess, setUploadSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resumeData, setResumeData] = useState<ResumeData | null>(null);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Parse the resume file client-side
|
||||
const parsedData = await parseResumeFile(file);
|
||||
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'SAVE_RESUME_DATA',
|
||||
data: parsedData,
|
||||
});
|
||||
|
||||
setResumeData(parsedData);
|
||||
setUploadSuccess(true);
|
||||
} catch (err) {
|
||||
console.error('Error uploading resume:', err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Error uploading resume. Please try again.';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
window.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
bgcolor: 'background.default',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<AppBar
|
||||
position="static"
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 600 }}>
|
||||
JobSpeed - Upload Resume
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ opacity: 0.9 }}>
|
||||
Select your resume file to upload
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
color="inherit"
|
||||
startIcon={<CloseIcon />}
|
||||
onClick={handleClose}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{/* Main Content */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 4,
|
||||
maxWidth: 600,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
{uploading && (
|
||||
<>
|
||||
<Typography variant="h6" align="center">
|
||||
Uploading Resume...
|
||||
</Typography>
|
||||
<LinearProgress />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!uploading && !uploadSuccess && (
|
||||
<>
|
||||
<Typography variant="h6" align="center">
|
||||
Select Resume File
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
align="center"
|
||||
>
|
||||
Supported formats: PDF, DOCX
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
component="label"
|
||||
startIcon={<UploadIcon />}
|
||||
fullWidth
|
||||
size="large"
|
||||
sx={{
|
||||
background:
|
||||
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
textTransform: 'none',
|
||||
py: 2,
|
||||
}}
|
||||
>
|
||||
Choose File
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".pdf,.docx"
|
||||
onChange={(e) => void handleFileChange(e)}
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{uploadSuccess && resumeData && (
|
||||
<>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<CheckCircleIcon
|
||||
sx={{ fontSize: 64, color: 'success.main', mb: 2 }}
|
||||
/>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Resume Uploaded Successfully!
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Alert severity="success">
|
||||
Your resume data has been saved and is ready to use for
|
||||
auto-filling job applications.
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ bgcolor: '#f8f9fa', p: 2, borderRadius: 1 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ mb: 1, fontWeight: 600 }}
|
||||
>
|
||||
Extracted Data
|
||||
</Typography>
|
||||
<Stack
|
||||
spacing={0.5}
|
||||
sx={{ fontSize: '0.875rem', color: '#666' }}
|
||||
>
|
||||
{resumeData.personalInfo.fullName && (
|
||||
<Typography variant="body2">
|
||||
<strong>Name:</strong>{' '}
|
||||
{resumeData.personalInfo.fullName}
|
||||
</Typography>
|
||||
)}
|
||||
{resumeData.personalInfo.email && (
|
||||
<Typography variant="body2">
|
||||
<strong>Email:</strong>{' '}
|
||||
{resumeData.personalInfo.email}
|
||||
</Typography>
|
||||
)}
|
||||
{resumeData.personalInfo.phone && (
|
||||
<Typography variant="body2">
|
||||
<strong>Phone:</strong>{' '}
|
||||
{resumeData.personalInfo.phone}
|
||||
</Typography>
|
||||
)}
|
||||
{resumeData.personalInfo.address && (
|
||||
<Typography variant="body2">
|
||||
<strong>Address:</strong>{' '}
|
||||
{resumeData.personalInfo.address}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleClose}
|
||||
fullWidth
|
||||
sx={{
|
||||
background:
|
||||
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
textTransform: 'none',
|
||||
py: 1.5,
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Mount the React app
|
||||
const container = document.getElementById('root');
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(<UploadApp />);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import mammoth from 'mammoth';
|
||||
import type { ResumeData } from '../types';
|
||||
|
||||
// Configure PDF.js worker
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
/**
|
||||
* Extract text from a PDF file
|
||||
*/
|
||||
async function extractTextFromPDF(file: File): Promise<string> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
|
||||
let fullText = '';
|
||||
|
||||
// Extract text from each page
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const textContent = await page.getTextContent();
|
||||
const pageText = textContent.items
|
||||
.map((item: any) => item.str)
|
||||
.join(' ');
|
||||
fullText += pageText + '\n';
|
||||
}
|
||||
|
||||
return fullText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from a DOCX file
|
||||
*/
|
||||
async function extractTextFromDOCX(file: File): Promise<string> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const result = await mammoth.extractRawText({ arrayBuffer });
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse resume text to extract structured data
|
||||
*/
|
||||
function parseResumeText(text: string): ResumeData {
|
||||
const lines = text.split('\n').filter((line) => line.trim());
|
||||
|
||||
return {
|
||||
personalInfo: {
|
||||
fullName:
|
||||
lines
|
||||
.find((line) => line.match(/^[A-Z][a-z]+ [A-Z][a-z]+/))
|
||||
?.trim() || '',
|
||||
email:
|
||||
lines
|
||||
.find((line) =>
|
||||
line.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/)
|
||||
)
|
||||
?.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/)?.[0] || '',
|
||||
phone:
|
||||
lines
|
||||
.find((line) =>
|
||||
line.match(/(\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/)
|
||||
)
|
||||
?.match(/(\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/)?.[0] || '',
|
||||
address:
|
||||
lines
|
||||
.find((line) => line.match(/\d+\s+\w+\s+(St|Ave|Rd|Blvd|Ln|Dr)/i))
|
||||
?.trim() || '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
country: '',
|
||||
},
|
||||
workExperience: [],
|
||||
education: [],
|
||||
skills: [],
|
||||
summary: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to parse a resume file (PDF or DOCX)
|
||||
*/
|
||||
export async function parseResumeFile(file: File): Promise<ResumeData> {
|
||||
const fileName = file.name.toLowerCase();
|
||||
let text = '';
|
||||
|
||||
if (fileName.endsWith('.pdf')) {
|
||||
text = await extractTextFromPDF(file);
|
||||
} else if (fileName.endsWith('.docx')) {
|
||||
text = await extractTextFromDOCX(file);
|
||||
} else {
|
||||
throw new Error('Unsupported file type. Only PDF and DOCX files are supported.');
|
||||
}
|
||||
|
||||
return parseResumeText(text);
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"ui": "tui",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".next/**", "build/**"],
|
||||
"inputs": ["src/**", "tsconfig.json", "vite.config.ts", "package.json"]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"start": {
|
||||
"cache": false,
|
||||
"persistent": true,
|
||||
"dependsOn": ["build"]
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["coverage/**"],
|
||||
"inputs": ["src/**", "test/**", "vitest.config.ts", "tsconfig.json"]
|
||||
},
|
||||
"test:ui": {
|
||||
"cache": false,
|
||||
"persistent": true,
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"test:coverage": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["coverage/**"],
|
||||
"inputs": ["src/**", "test/**", "vitest.config.ts", "tsconfig.json"]
|
||||
},
|
||||
"test:e2e": {
|
||||
"dependsOn": ["build"],
|
||||
"outputs": ["playwright-report/**", "test-results/**"],
|
||||
"inputs": ["src/**", "e2e/**", "playwright.config.ts"]
|
||||
},
|
||||
"test:e2e:ui": {
|
||||
"cache": false,
|
||||
"persistent": true,
|
||||
"dependsOn": ["build"]
|
||||
},
|
||||
"lint": {
|
||||
"outputs": [],
|
||||
"inputs": ["src/**", ".eslintrc.json", "tsconfig.json"]
|
||||
},
|
||||
"lint:fix": {
|
||||
"cache": false,
|
||||
"outputs": ["src/**"],
|
||||
"inputs": ["src/**", ".eslintrc.json", "tsconfig.json"]
|
||||
},
|
||||
"format": {
|
||||
"cache": false,
|
||||
"outputs": ["src/**"],
|
||||
"inputs": [".prettierrc", ".prettierignore"]
|
||||
},
|
||||
"format:check": {
|
||||
"outputs": [],
|
||||
"inputs": ["src/**", ".prettierrc", ".prettierignore"]
|
||||
},
|
||||
"copy:icons": {
|
||||
"outputs": ["dist/icons/**"],
|
||||
"inputs": ["src/icons/**"]
|
||||
},
|
||||
"build:upload": {
|
||||
"dependsOn": ["build"],
|
||||
"outputs": ["dist/upload.html", "dist/upload.js"],
|
||||
"inputs": ["src/upload.tsx", "src/upload.html", "scripts/build-upload.mjs"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user