feat(cv-export): add PDF export functionality with async job support
Implement PDF export feature with both synchronous and asynchronous modes. Includes: - New cv-export-server service using Puppeteer - Shared printable HTML builder module - ExportControls React component with job status tracking - Classic template for PDF output - API endpoints for job management The system supports cancelable async jobs with polling and error handling. Both client and server share the same HTML rendering logic via the shared-printable module.
This commit is contained in:
parent
adeb8473b7
commit
0ebf5fe3de
280
cv-engine/package-lock.json
generated
280
cv-engine/package-lock.json
generated
@ -13,6 +13,7 @@
|
||||
"@tiptap/react": "^3.6.5",
|
||||
"@tiptap/starter-kit": "^3.6.5",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"dompurify": "^3.2.7",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.1.1",
|
||||
@ -2658,6 +2659,12 @@
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.21",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||
@ -2695,6 +2702,17 @@
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@ -2768,6 +2786,19 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@ -2845,6 +2876,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@ -2911,6 +2954,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@ -2930,6 +2982,20 @@
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.230",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.230.tgz",
|
||||
@ -2962,6 +3028,51 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
|
||||
@ -3327,6 +3438,42 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
@ -3355,6 +3502,15 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@ -3365,6 +3521,43 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@ -3391,6 +3584,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@ -3415,6 +3620,45 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@ -3907,6 +4151,15 @@
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||
@ -3937,6 +4190,27 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@ -4367,6 +4641,12 @@
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"@tiptap/react": "^3.6.5",
|
||||
"@tiptap/starter-kit": "^3.6.5",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"dompurify": "^3.2.7",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.1.1",
|
||||
|
||||
66
cv-engine/src/api/export.ts
Normal file
66
cv-engine/src/api/export.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import axios from 'axios';
|
||||
import type { CV } from '../schema/cvSchema';
|
||||
|
||||
export interface ExportResult { filename: string; }
|
||||
export interface JobResponse { jobId: string; }
|
||||
export interface JobStatus { status: 'queued' | 'done' | 'error' | 'canceled'; filename?: string; error?: string; }
|
||||
|
||||
// Synchronous export (no job)
|
||||
export async function exportPdf(cv: CV, endpoint = 'http://localhost:4000/export/pdf', templateId?: string): Promise<ExportResult> {
|
||||
const response = await axios.post(endpoint, { ...cv, templateId }, { responseType: 'blob' });
|
||||
const contentDisposition = response.headers['content-disposition'] || '';
|
||||
const match = /filename="?([^";]+)"?/i.exec(contentDisposition);
|
||||
const filename = match ? match[1] : `${cv.personal.firstName || 'Candidate'}_${cv.personal.lastName || 'CV'}.pdf`;
|
||||
|
||||
const blob = new Blob([response.data], { type: 'application/pdf' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
return { filename };
|
||||
}
|
||||
|
||||
// Async export: request job, poll status with backoff, then download
|
||||
export async function requestExportJob(cv: CV, endpoint = 'http://localhost:4000/export/pdf', templateId?: string): Promise<string> {
|
||||
const response = await axios.post<JobResponse>(`${endpoint}?async=1`, { ...cv, templateId });
|
||||
return response.data.jobId;
|
||||
}
|
||||
|
||||
export async function pollJobStatus(jobId: string, baseUrl = 'http://localhost:4000', initialDelayMs = 500, maxDelayMs = 5000, maxAttempts = 10): Promise<JobStatus> {
|
||||
let attempt = 0;
|
||||
let delay = initialDelayMs;
|
||||
while (attempt < maxAttempts) {
|
||||
const { data } = await axios.get<JobStatus>(`${baseUrl}/export/status/${jobId}`);
|
||||
if (data.status === 'done' || data.status === 'error' || data.status === 'canceled') return data;
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
delay = Math.min(Math.round(delay * 1.6), maxDelayMs);
|
||||
attempt += 1;
|
||||
}
|
||||
return { status: 'error', error: 'Timeout waiting for export' };
|
||||
}
|
||||
|
||||
export async function downloadJob(jobId: string, baseUrl = 'http://localhost:4000'): Promise<ExportResult> {
|
||||
const response = await axios.get(`${baseUrl}/export/download/${jobId}`, { responseType: 'blob' });
|
||||
const contentDisposition = response.headers['content-disposition'] || '';
|
||||
const match = /filename="?([^";]+)"?/i.exec(contentDisposition);
|
||||
const filename = match ? match[1] : `CV.pdf`;
|
||||
const blob = new Blob([response.data], { type: 'application/pdf' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
return { filename };
|
||||
}
|
||||
|
||||
export async function cancelJob(jobId: string, baseUrl = 'http://localhost:4000'): Promise<void> {
|
||||
await axios.post(`${baseUrl}/export/cancel/${jobId}`);
|
||||
}
|
||||
149
cv-engine/src/components/ExportControls.tsx
Normal file
149
cv-engine/src/components/ExportControls.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useCvStore } from '../store/cvStore';
|
||||
import { exportPdf, requestExportJob, pollJobStatus, downloadJob, cancelJob } from '../api/export';
|
||||
|
||||
interface ExportControlsProps {
|
||||
endpoint?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ExportControls: React.FC<ExportControlsProps> = ({ endpoint = 'http://localhost:4000/export/pdf', className = '' }) => {
|
||||
const cv = useCvStore(state => state.cv);
|
||||
const templateId = useCvStore(state => state.cv.templateId);
|
||||
const updateTemplateId = useCvStore(state => state.updateTemplateId);
|
||||
const [status, setStatus] = useState<'idle' | 'exporting' | 'error' | 'done'>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastFilename, setLastFilename] = useState<string | null>(null);
|
||||
const [asyncMode, setAsyncMode] = useState<boolean>(true);
|
||||
const currentJobId = useRef<string | null>(null);
|
||||
|
||||
async function handleExport() {
|
||||
setStatus('exporting');
|
||||
setError(null);
|
||||
try {
|
||||
if (!asyncMode) {
|
||||
const { filename } = await exportPdf(cv, endpoint, templateId);
|
||||
setLastFilename(filename);
|
||||
setStatus('done');
|
||||
setTimeout(() => setStatus('idle'), 1500);
|
||||
return;
|
||||
}
|
||||
const jobId = await requestExportJob(cv, endpoint, templateId);
|
||||
currentJobId.current = jobId;
|
||||
const statusResult = await pollJobStatus(jobId, new URL(endpoint).origin);
|
||||
if (statusResult.status === 'error') {
|
||||
throw new Error(statusResult.error || 'Export job failed');
|
||||
}
|
||||
if (statusResult.status === 'canceled') {
|
||||
setStatus('idle');
|
||||
setError('Export canceled');
|
||||
currentJobId.current = null;
|
||||
return;
|
||||
}
|
||||
const { filename } = await downloadJob(jobId, new URL(endpoint).origin);
|
||||
setLastFilename(filename);
|
||||
setStatus('done');
|
||||
setTimeout(() => setStatus('idle'), 1500);
|
||||
} catch (err: unknown) {
|
||||
let message = 'Export failed';
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
const resp = (err as { response?: { data?: { error?: string } } }).response;
|
||||
if (resp?.data?.error) {
|
||||
message = resp.data.error;
|
||||
} else if ('message' in err && typeof (err as { message?: string }).message === 'string') {
|
||||
message = (err as { message?: string }).message as string;
|
||||
}
|
||||
}
|
||||
setError(message);
|
||||
setStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!currentJobId.current) return;
|
||||
try {
|
||||
await cancelJob(currentJobId.current, new URL(endpoint).origin);
|
||||
setStatus('idle');
|
||||
setError(null);
|
||||
currentJobId.current = null;
|
||||
} catch (err) {
|
||||
// Keep subtle; cancellation failures shouldn't block
|
||||
console.warn('Cancel failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetry() {
|
||||
// reset and re-run export
|
||||
setError(null);
|
||||
await handleExport();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<label className="text-xs text-gray-600">Template</label>
|
||||
<select
|
||||
className="px-2 py-1 text-xs border rounded"
|
||||
value={templateId}
|
||||
onChange={e => updateTemplateId(e.target.value)}
|
||||
disabled={status === 'exporting'}
|
||||
>
|
||||
<option value="ats">ATS-Friendly</option>
|
||||
<option value="classic">Classic</option>
|
||||
</select>
|
||||
|
||||
<label className="ml-2 text-xs text-gray-600">Async</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="ml-1"
|
||||
checked={asyncMode}
|
||||
onChange={e => setAsyncMode(e.target.checked)}
|
||||
disabled={status === 'exporting'}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={status === 'exporting'}
|
||||
className={`px-3 py-1.5 rounded-md text-sm border ${status === 'exporting' ? 'bg-gray-300 text-gray-700 border-gray-300' : 'bg-green-600 text-white border-green-600 hover:bg-green-700'}`}
|
||||
title="Export to PDF"
|
||||
>
|
||||
{status === 'exporting' ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="animate-spin inline-block w-3 h-3 border-2 border-white border-t-transparent rounded-full" />
|
||||
Exporting…
|
||||
</span>
|
||||
) : 'Export PDF'}
|
||||
</button>
|
||||
|
||||
{status === 'exporting' && asyncMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-2 py-1 rounded-md text-xs border bg-gray-100 text-gray-800 border-gray-300 hover:bg-gray-200"
|
||||
title="Cancel export job"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
className="px-2 py-1 rounded-md text-xs border bg-orange-500 text-white border-orange-500 hover:bg-orange-600"
|
||||
title="Retry export"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<span className="text-xs text-red-600">{error}</span>
|
||||
)}
|
||||
{status === 'done' && lastFilename && (
|
||||
<span className="text-xs text-gray-600">Downloaded: {lastFilename}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportControls;
|
||||
@ -1,7 +1,9 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useCvStore } from '../store/cvStore';
|
||||
import ATSTemplate from '../templates/ATSTemplate';
|
||||
import ClassicTemplate from '../templates/ClassicTemplate';
|
||||
import { buildPrintableHtml } from '../utils/printable';
|
||||
import ExportControls from './ExportControls';
|
||||
|
||||
interface PreviewPanelProps {
|
||||
className?: string;
|
||||
@ -37,6 +39,7 @@ const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
|
||||
Printable
|
||||
</button>
|
||||
</div>
|
||||
<ExportControls className="ml-3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -45,6 +48,7 @@ const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
|
||||
<>
|
||||
{/* Render the appropriate template based on templateId */}
|
||||
{templateId === 'ats' && <ATSTemplate cv={cv} />}
|
||||
{templateId === 'classic' && <ClassicTemplate cv={cv} />}
|
||||
{/* Add more template options here as they are implemented */}
|
||||
</>
|
||||
)}
|
||||
|
||||
133
cv-engine/src/templates/ClassicTemplate.tsx
Normal file
133
cv-engine/src/templates/ClassicTemplate.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import React from 'react';
|
||||
import type { CV } from '../schema/cvSchema';
|
||||
|
||||
interface ClassicTemplateProps {
|
||||
cv: CV;
|
||||
}
|
||||
|
||||
// Simple classic visual template for inline preview
|
||||
const ClassicTemplate: React.FC<ClassicTemplateProps> = ({ cv }) => {
|
||||
const personal = cv.personal || {};
|
||||
const summary = cv.summary || '';
|
||||
const work = cv.work || [];
|
||||
const education = cv.education || [];
|
||||
const skills = cv.skills || [];
|
||||
const languages = cv.languages || [];
|
||||
const certifications = cv.certifications || [];
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-md shadow text-gray-800">
|
||||
<header className="border-b pb-3 mb-4">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{personal.firstName} {personal.lastName}
|
||||
</h1>
|
||||
<div className="mt-1 text-sm text-gray-600 flex flex-wrap gap-2">
|
||||
{personal.email && <span>{personal.email}</span>}
|
||||
{personal.phone && <span>{personal.phone}</span>}
|
||||
{(personal.city || personal.country) && (
|
||||
<span>
|
||||
{personal.city}{personal.city && personal.country ? ', ' : ''}{personal.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{summary && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-700 mb-2">Professional Summary</h2>
|
||||
<div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: summary }} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{work.length > 0 && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-700 mb-2">Work Experience</h2>
|
||||
<div className="space-y-3">
|
||||
{work.map((job) => (
|
||||
<article key={job.id} className="">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">{job.title}</h3>
|
||||
<div className="text-sm text-gray-600">{job.company}</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{job.startDate} - {job.endDate || 'Present'}
|
||||
{job.location && <div>{job.location}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{job.bullets && job.bullets.length > 0 && (
|
||||
<ul className="list-disc pl-6 mt-2">
|
||||
{job.bullets.map((b, i) => (
|
||||
<li key={i}>{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{education.length > 0 && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-700 mb-2">Education</h2>
|
||||
<div className="space-y-3">
|
||||
{education.map((ed) => (
|
||||
<article key={ed.id}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">{ed.degree}</h3>
|
||||
<div className="text-sm text-gray-600">{ed.school}</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{ed.startDate} {ed.endDate && <>- {ed.endDate}</>}
|
||||
</div>
|
||||
</div>
|
||||
{/* GPA not present in schema; omit for now */}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{skills.length > 0 && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-700 mb-2">Skills</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills.map((s) => (
|
||||
<span key={s.id} className="px-2 py-1 rounded-full bg-gray-100 text-gray-800 text-sm">
|
||||
{s.name}{s.level ? ` — ${s.level}` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{languages.length > 0 && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-700 mb-2">Languages</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{languages.map((l) => (
|
||||
<span key={l.id} className="px-2 py-1 rounded-full bg-gray-100 text-gray-800 text-sm">
|
||||
{l.name}{l.level ? ` — ${l.level}` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{certifications.length > 0 && (
|
||||
<section className="mb-2">
|
||||
<h2 className="text-lg font-medium text-gray-700 mb-2">Certifications</h2>
|
||||
<ul className="list-disc pl-6">
|
||||
{certifications.map((c) => (
|
||||
<li key={c.id}>{c.name}{c.issuer ? ` - ${c.issuer}` : ''}{c.date ? ` (${c.date})` : ''}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClassicTemplate;
|
||||
4
cv-engine/src/types/shared-printable.d.ts
vendored
Normal file
4
cv-engine/src/types/shared-printable.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module '../../../shared-printable/buildPrintableHtml.js' {
|
||||
import type { CV } from '../schema/cvSchema';
|
||||
export function buildPrintableHtml(cv: CV): string;
|
||||
}
|
||||
@ -1,156 +1,12 @@
|
||||
import type { CV } from '../schema/cvSchema';
|
||||
import { escapeText, sanitizeHtml } from '../schema/cvSchema';
|
||||
|
||||
const formatDate = (dateStr?: string): string => {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
import { sanitizeHtml } from '../schema/cvSchema';
|
||||
import { buildPrintableHtml as sharedBuild } from '../../../shared-printable/buildPrintableHtml.js';
|
||||
|
||||
// Thin wrapper to ensure client-side sanitization and shared rendering parity
|
||||
export const buildPrintableHtml = (cv: CV): string => {
|
||||
const { personal, summary, work, education, skills, languages, certifications } = cv;
|
||||
|
||||
const summaryHtml = summary ? sanitizeHtml(summary) : '';
|
||||
|
||||
const styles = `
|
||||
<style>
|
||||
@page { size: A4; margin: 20mm; }
|
||||
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans, "Apple Color Emoji", "Segoe UI Emoji"; color: #1f2937; }
|
||||
.page { width: 210mm; min-height: 297mm; margin: 0 auto; background: white; }
|
||||
h1 { font-size: 24px; margin: 0 0 6px 0; }
|
||||
h2 { font-size: 16px; margin: 16px 0 8px 0; padding-bottom: 4px; border-bottom: 1px solid #e5e7eb; color: #374151; }
|
||||
h3 { font-size: 14px; margin: 0; }
|
||||
.header { border-bottom: 1px solid #e5e7eb; padding-bottom: 12px; margin-bottom: 16px; }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #6b7280; }
|
||||
.section { margin-bottom: 16px; }
|
||||
.job, .edu { margin-bottom: 12px; }
|
||||
.row { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.small { font-size: 12px; color: #6b7280; }
|
||||
ul { padding-left: 20px; }
|
||||
li { margin: 4px 0; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip { background: #f3f4f6; color: #1f2937; padding: 2px 8px; border-radius: 12px; font-size: 12px; }
|
||||
</style>
|
||||
`;
|
||||
|
||||
const headerLinks = (personal.links || [])
|
||||
.map(l => `<a href="${escapeText(l.url)}" target="_blank" rel="noopener noreferrer">${escapeText(l.label || l.url)}</a>`)
|
||||
.join(' · ');
|
||||
|
||||
const headerHtml = `
|
||||
<div class="header">
|
||||
<h1>${escapeText(personal.firstName)} ${escapeText(personal.lastName)}</h1>
|
||||
<div class="meta">
|
||||
${personal.email ? `<span>${escapeText(personal.email)}</span>` : ''}
|
||||
${personal.phone ? `<span>${escapeText(personal.phone)}</span>` : ''}
|
||||
${(personal.city || personal.country) ? `<span>${personal.city ? escapeText(personal.city) : ''}${personal.city && personal.country ? ', ' : ''}${personal.country ? escapeText(personal.country) : ''}</span>` : ''}
|
||||
${headerLinks ? `<span>${headerLinks}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const summarySection = summaryHtml ? `
|
||||
<div class="section">
|
||||
<h2>Professional Summary</h2>
|
||||
<div>${summaryHtml}</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const workSection = (work && work.length) ? `
|
||||
<div class="section">
|
||||
<h2>Work Experience</h2>
|
||||
${work.map(job => `
|
||||
<div class="job">
|
||||
<div class="row">
|
||||
<div>
|
||||
<h3>${escapeText(job.title)}</h3>
|
||||
<div class="small">${escapeText(job.company)}</div>
|
||||
</div>
|
||||
<div class="small">
|
||||
${formatDate(job.startDate)} - ${job.endDate ? formatDate(job.endDate) : 'Present'}
|
||||
${job.location ? `<div>${escapeText(job.location)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${(job.bullets && job.bullets.length) ? `
|
||||
<ul>
|
||||
${job.bullets.map(b => `<li>${escapeText(b)}</li>`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const eduSection = (education && education.length) ? `
|
||||
<div class="section">
|
||||
<h2>Education</h2>
|
||||
${education.map(edu => `
|
||||
<div class="edu">
|
||||
<div class="row">
|
||||
<div>
|
||||
<h3>${escapeText(edu.degree)}</h3>
|
||||
<div class="small">${escapeText(edu.school)}</div>
|
||||
</div>
|
||||
<div class="small">
|
||||
${formatDate(edu.startDate)} - ${edu.endDate ? formatDate(edu.endDate) : 'Present'}
|
||||
</div>
|
||||
</div>
|
||||
${edu.notes ? `<div class="small">${escapeText(edu.notes)}</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const skillsSection = (skills && skills.length) ? `
|
||||
<div class="section">
|
||||
<h2>Skills</h2>
|
||||
<div class="chips">
|
||||
${skills.map(s => `<span class="chip">${escapeText(s.name)}${s.level ? ` (${s.level})` : ''}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const languagesSection = (languages && languages.length) ? `
|
||||
<div class="section">
|
||||
<h2>Languages</h2>
|
||||
<div class="chips">
|
||||
${languages.map(l => `<span class="chip">${escapeText(l.name)}${l.level ? ` (${l.level})` : ''}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const certsSection = (certifications && certifications.length) ? `
|
||||
<div class="section">
|
||||
<h2>Certifications</h2>
|
||||
<ul>
|
||||
${certifications.map(c => `<li>${escapeText(c.name)}${c.issuer ? ` - ${escapeText(c.issuer)}` : ''}${c.date ? ` (${formatDate(c.date)})` : ''}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Printable CV</title>
|
||||
${styles}
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
${headerHtml}
|
||||
${summarySection}
|
||||
${workSection}
|
||||
${eduSection}
|
||||
${skillsSection}
|
||||
${languagesSection}
|
||||
${certsSection}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
const cleaned: CV = {
|
||||
...cv,
|
||||
summary: cv.summary ? sanitizeHtml(cv.summary) : ''
|
||||
};
|
||||
return sharedBuild(cleaned);
|
||||
};
|
||||
@ -24,5 +24,8 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": [
|
||||
"src",
|
||||
"../shared-printable/buildPrintableHtml.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@ -4,4 +4,9 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
fs: {
|
||||
allow: ['..']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
24
cv-export-server/.gitignore
vendored
Normal file
24
cv-export-server/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
2123
cv-export-server/package-lock.json
generated
Normal file
2123
cv-export-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
cv-export-server/package.json
Normal file
21
cv-export-server/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "cv-export-server",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "NODE_ENV=development node server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"puppeteer": "^24.23.0",
|
||||
"sanitize-html": "^2.17.0"
|
||||
}
|
||||
}
|
||||
281
cv-export-server/server.js
Normal file
281
cv-export-server/server.js
Normal file
@ -0,0 +1,281 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { buildPrintableHtml as sharedBuild } from '../shared-printable/buildPrintableHtml.js';
|
||||
|
||||
// Minimal escape function to avoid breaking HTML; summary is sanitized client-side.
|
||||
function escapeText(str) {
|
||||
if (str == null) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function buildPrintableHtml(cv) {
|
||||
const { personal = {}, summary = '', work = [], education = [], skills = [], languages = [], certifications = [] } = cv || {};
|
||||
const styles = `
|
||||
<style>
|
||||
@page { size: A4; margin: 20mm; }
|
||||
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans; color: #1f2937; }
|
||||
.page { width: 210mm; min-height: 297mm; margin: 0 auto; background: white; }
|
||||
h1 { font-size: 24px; margin: 0 0 6px 0; }
|
||||
h2 { font-size: 16px; margin: 16px 0 8px 0; padding-bottom: 4px; border-bottom: 1px solid #e5e7eb; color: #374151; }
|
||||
h3 { font-size: 14px; margin: 0; }
|
||||
.header { border-bottom: 1px solid #e5e7eb; padding-bottom: 12px; margin-bottom: 16px; }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #6b7280; }
|
||||
.section { margin-bottom: 16px; }
|
||||
.job, .edu { margin-bottom: 12px; }
|
||||
.row { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.small { font-size: 12px; color: #6b7280; }
|
||||
ul { padding-left: 20px; }
|
||||
li { margin: 4px 0; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip { background: #f3f4f6; color: #1f2937; padding: 2px 8px; border-radius: 12px; font-size: 12px; }
|
||||
</style>
|
||||
`;
|
||||
|
||||
const headerLinks = (personal.links || [])
|
||||
.map(l => `<a href="${escapeText(l.url)}" target="_blank" rel="noopener noreferrer">${escapeText(l.label || l.url)}</a>`)
|
||||
.join(' · ');
|
||||
|
||||
const headerHtml = `
|
||||
<div class="header">
|
||||
<h1>${escapeText(personal.firstName || '')} ${escapeText(personal.lastName || '')}</h1>
|
||||
<div class="meta">
|
||||
${personal.email ? `<span>${escapeText(personal.email)}</span>` : ''}
|
||||
${personal.phone ? `<span>${escapeText(personal.phone)}</span>` : ''}
|
||||
${(personal.city || personal.country) ? `<span>${personal.city ? escapeText(personal.city) : ''}${personal.city && personal.country ? ', ' : ''}${personal.country ? escapeText(personal.country) : ''}</span>` : ''}
|
||||
${headerLinks ? `<span>${headerLinks}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const cleanSummary = typeof summary === 'string' ? sanitizeHtml(summary, {
|
||||
allowedTags: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'a'],
|
||||
allowedAttributes: { a: ['href', 'rel', 'target'] }
|
||||
}) : '';
|
||||
|
||||
const summarySection = cleanSummary ? `
|
||||
<div class="section">
|
||||
<h2>Professional Summary</h2>
|
||||
<div>${cleanSummary}</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const workSection = (work && work.length) ? `
|
||||
<div class="section">
|
||||
<h2>Work Experience</h2>
|
||||
${work.map(job => `
|
||||
<div class="job">
|
||||
<div class="row">
|
||||
<div>
|
||||
<h3>${escapeText(job.title)}</h3>
|
||||
<div class="small">${escapeText(job.company)}</div>
|
||||
</div>
|
||||
<div class="small">
|
||||
${escapeText(job.startDate || '')} - ${escapeText(job.endDate || 'Present')}
|
||||
${job.location ? `<div>${escapeText(job.location)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${(job.bullets && job.bullets.length) ? `
|
||||
<ul>
|
||||
${job.bullets.map(b => `<li>${escapeText(b)}</li>`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const eduSection = (education && education.length) ? `
|
||||
<div class="section">
|
||||
<h2>Education</h2>
|
||||
${education.map(ed => `
|
||||
<div class="edu">
|
||||
<div class="row">
|
||||
<div>
|
||||
<h3>${escapeText(ed.degree)}</h3>
|
||||
<div class="small">${escapeText(ed.school)}</div>
|
||||
</div>
|
||||
<div class="small">
|
||||
${escapeText(ed.startDate || '')} - ${escapeText(ed.endDate || '')}
|
||||
</div>
|
||||
</div>
|
||||
${ed.gpa ? `<div class="small">GPA: ${escapeText(ed.gpa)}</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const skillsSection = (skills && skills.length) ? `
|
||||
<div class="section">
|
||||
<h2>Skills</h2>
|
||||
<div class="chips">
|
||||
${skills.map(s => `<span class="chip">${escapeText(s.name)}${s.level ? ` — ${escapeText(s.level)}` : ''}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const languagesSection = (languages && languages.length) ? `
|
||||
<div class="section">
|
||||
<h2>Languages</h2>
|
||||
<div class="chips">
|
||||
${languages.map(l => `<span class="chip">${escapeText(l.name)}${l.level ? ` — ${escapeText(l.level)}` : ''}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const certsSection = (certifications && certifications.length) ? `
|
||||
<div class="section">
|
||||
<h2>Certifications</h2>
|
||||
<ul>
|
||||
${certifications.map(c => `<li>${escapeText(c.name)}${c.issuer ? ` - ${escapeText(c.issuer)}` : ''}${c.date ? ` (${escapeText(c.date)})` : ''}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Printable CV</title>
|
||||
${styles}
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
${headerHtml}
|
||||
${summarySection}
|
||||
${workSection}
|
||||
${eduSection}
|
||||
${skillsSection}
|
||||
${languagesSection}
|
||||
${certsSection}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// Pluggable job store with in-memory default
|
||||
class InMemoryJobStore {
|
||||
constructor() {
|
||||
this.map = new Map();
|
||||
}
|
||||
create(job) {
|
||||
const id = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
this.map.set(id, { ...job, id });
|
||||
return id;
|
||||
}
|
||||
get(id) { return this.map.get(id); }
|
||||
set(id, data) { this.map.set(id, { ...(this.map.get(id) || {}), ...data }); }
|
||||
cancel(id) {
|
||||
const job = this.map.get(id);
|
||||
if (!job) return false;
|
||||
this.map.set(id, { ...job, status: 'canceled', error: null, buffer: null });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const jobs = new InMemoryJobStore();
|
||||
|
||||
app.post('/export/pdf', async (req, res) => {
|
||||
try {
|
||||
// Treat presence of the query param as async, and allow common truthy values
|
||||
const asyncFlag = (
|
||||
typeof req.query.async !== 'undefined'
|
||||
? ['1', 'true', 'on', ''].includes(String(req.query.async).toLowerCase())
|
||||
: req.body?.async === true
|
||||
);
|
||||
const templateId = req.query.templateId || req.body?.templateId || null;
|
||||
const cv = req.body || {};
|
||||
const sanitizedCv = {
|
||||
...cv,
|
||||
summary: typeof cv.summary === 'string' ? sanitizeHtml(cv.summary, {
|
||||
allowedTags: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'a'],
|
||||
allowedAttributes: { a: ['href', 'rel', 'target'] }
|
||||
}) : ''
|
||||
};
|
||||
|
||||
const firstName = (sanitizedCv.personal && sanitizedCv.personal.firstName) ? sanitizedCv.personal.firstName : 'Candidate';
|
||||
const lastName = (sanitizedCv.personal && sanitizedCv.personal.lastName) ? sanitizedCv.personal.lastName : 'CV';
|
||||
const filename = `${firstName}_${lastName}_CV.pdf`.replace(/\s+/g, '_');
|
||||
|
||||
if (asyncFlag) {
|
||||
const jobId = jobs.create({ status: 'queued', filename, error: null, buffer: null, templateId });
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const current = jobs.get(jobId);
|
||||
if (current?.status === 'canceled') return; // honor cancel
|
||||
const html = sharedBuild(sanitizedCv);
|
||||
const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
const pdfBuffer = await page.pdf({ format: 'A4', printBackground: true });
|
||||
await browser.close();
|
||||
jobs.set(jobId, { status: 'done', filename, error: null, buffer: pdfBuffer, templateId });
|
||||
} catch (e) {
|
||||
console.error('Async export error:', e);
|
||||
jobs.set(jobId, { status: 'error', filename, error: 'Failed to create PDF', buffer: null, templateId });
|
||||
}
|
||||
});
|
||||
return res.json({ jobId });
|
||||
}
|
||||
|
||||
// Synchronous path
|
||||
const html = sharedBuild(sanitizedCv);
|
||||
const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
const pdfBuffer = await page.pdf({ format: 'A4', printBackground: true });
|
||||
await browser.close();
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`
|
||||
});
|
||||
res.send(pdfBuffer);
|
||||
} catch (err) {
|
||||
console.error('Export error:', err);
|
||||
res.status(500).json({ error: 'Failed to create PDF' });
|
||||
}
|
||||
});
|
||||
|
||||
// Job status endpoint
|
||||
app.get('/export/status/:id', (req, res) => {
|
||||
const job = jobs.get(req.params.id);
|
||||
if (!job) return res.status(404).json({ error: 'Job not found' });
|
||||
res.json({ status: job.status, filename: job.filename, error: job.error });
|
||||
});
|
||||
|
||||
// Job download endpoint
|
||||
app.get('/export/download/:id', (req, res) => {
|
||||
const job = jobs.get(req.params.id);
|
||||
if (!job) return res.status(404).json({ error: 'Job not found' });
|
||||
if (job.status !== 'done' || !job.buffer) return res.status(409).json({ error: 'Job not ready' });
|
||||
res.set({
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${job.filename}"`
|
||||
});
|
||||
res.send(job.buffer);
|
||||
});
|
||||
|
||||
// Cancel job endpoint
|
||||
app.post('/export/cancel/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
const ok = jobs.cancel(id);
|
||||
if (!ok) return res.status(404).json({ error: 'Job not found' });
|
||||
return res.json({ status: 'canceled' });
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 4000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Export server listening on http://localhost:${PORT}`);
|
||||
});
|
||||
11
headers.txt
Normal file
11
headers.txt
Normal file
@ -0,0 +1,11 @@
|
||||
HTTP/1.1 200 OK
|
||||
X-Powered-By: Express
|
||||
Access-Control-Allow-Origin: *
|
||||
Content-Type: application/pdf
|
||||
Content-Disposition: attachment; filename="Ada_Lovelace_CV.pdf"
|
||||
Content-Length: 13298
|
||||
ETag: W/"33f2-1gLBdxasSpROFsvw/XUGR63Xjhg"
|
||||
Date: Sun, 05 Oct 2025 23:50:44 GMT
|
||||
Connection: keep-alive
|
||||
Keep-Alive: timeout=5
|
||||
|
||||
2
shared-printable/buildPrintableHtml.d.ts
vendored
Normal file
2
shared-printable/buildPrintableHtml.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export type CV = import('../cv-engine/src/schema/cvSchema').CV;
|
||||
export function buildPrintableHtml(cv: CV): string;
|
||||
152
shared-printable/buildPrintableHtml.js
Normal file
152
shared-printable/buildPrintableHtml.js
Normal file
@ -0,0 +1,152 @@
|
||||
// ESM module: shared printable HTML builder
|
||||
// Note: callers must sanitize any HTML fields (e.g., summary) before passing.
|
||||
|
||||
function escapeText(str) {
|
||||
if (str == null) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function buildPrintableHtml(cv) {
|
||||
const { personal = {}, summary = '', work = [], education = [], skills = [], languages = [], certifications = [] } = cv || {};
|
||||
const styles = `
|
||||
<style>
|
||||
@page { size: A4; margin: 20mm; }
|
||||
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans; color: #1f2937; }
|
||||
.page { width: 210mm; min-height: 297mm; margin: 0 auto; background: white; }
|
||||
h1 { font-size: 24px; margin: 0 0 6px 0; }
|
||||
h2 { font-size: 16px; margin: 16px 0 8px 0; padding-bottom: 4px; border-bottom: 1px solid #e5e7eb; color: #374151; }
|
||||
h3 { font-size: 14px; margin: 0; }
|
||||
.header { border-bottom: 1px solid #e5e7eb; padding-bottom: 12px; margin-bottom: 16px; }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #6b7280; }
|
||||
.section { margin-bottom: 16px; }
|
||||
.job, .edu { margin-bottom: 12px; }
|
||||
.row { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.small { font-size: 12px; color: #6b7280; }
|
||||
ul { padding-left: 20px; }
|
||||
li { margin: 4px 0; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip { background: #f3f4f6; color: #1f2937; padding: 2px 8px; border-radius: 12px; font-size: 12px; }
|
||||
</style>
|
||||
`;
|
||||
|
||||
const headerLinks = (personal.links || [])
|
||||
.map(l => `<a href="${escapeText(l.url)}" target="_blank" rel="noopener noreferrer">${escapeText(l.label || l.url)}</a>`)
|
||||
.join(' · ');
|
||||
|
||||
const headerHtml = `
|
||||
<div class="header">
|
||||
<h1>${escapeText(personal.firstName || '')} ${escapeText(personal.lastName || '')}</h1>
|
||||
<div class="meta">
|
||||
${personal.email ? `<span>${escapeText(personal.email)}</span>` : ''}
|
||||
${personal.phone ? `<span>${escapeText(personal.phone)}</span>` : ''}
|
||||
${(personal.city || personal.country) ? `<span>${personal.city ? escapeText(personal.city) : ''}${personal.city && personal.country ? ', ' : ''}${personal.country ? escapeText(personal.country) : ''}</span>` : ''}
|
||||
${headerLinks ? `<span>${headerLinks}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const summarySection = summary ? `
|
||||
<div class="section">
|
||||
<h2>Professional Summary</h2>
|
||||
<div>${summary}</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const workSection = (work && work.length) ? `
|
||||
<div class="section">
|
||||
<h2>Work Experience</h2>
|
||||
${work.map(job => `
|
||||
<div class="job">
|
||||
<div class="row">
|
||||
<div>
|
||||
<h3>${escapeText(job.title)}</h3>
|
||||
<div class="small">${escapeText(job.company)}</div>
|
||||
</div>
|
||||
<div class="small">
|
||||
${escapeText(job.startDate || '')} - ${escapeText(job.endDate || 'Present')}
|
||||
${job.location ? `<div>${escapeText(job.location)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${(job.bullets && job.bullets.length) ? `
|
||||
<ul>
|
||||
${job.bullets.map(b => `<li>${escapeText(b)}</li>`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const eduSection = (education && education.length) ? `
|
||||
<div class="section">
|
||||
<h2>Education</h2>
|
||||
${education.map(ed => `
|
||||
<div class="edu">
|
||||
<div class="row">
|
||||
<div>
|
||||
<h3>${escapeText(ed.degree)}</h3>
|
||||
<div class="small">${escapeText(ed.school)}</div>
|
||||
</div>
|
||||
<div class="small">
|
||||
${escapeText(ed.startDate || '')} - ${escapeText(ed.endDate || '')}
|
||||
</div>
|
||||
</div>
|
||||
${ed.gpa ? `<div class="small">GPA: ${escapeText(ed.gpa)}</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const skillsSection = (skills && skills.length) ? `
|
||||
<div class="section">
|
||||
<h2>Skills</h2>
|
||||
<div class="chips">
|
||||
${skills.map(s => `<span class="chip">${escapeText(s.name)}${s.level ? ` — ${escapeText(s.level)}` : ''}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const languagesSection = (languages && languages.length) ? `
|
||||
<div class="section">
|
||||
<h2>Languages</h2>
|
||||
<div class="chips">
|
||||
${languages.map(l => `<span class="chip">${escapeText(l.name)}${l.level ? ` — ${escapeText(l.level)}` : ''}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const certsSection = (certifications && certifications.length) ? `
|
||||
<div class="section">
|
||||
<h2>Certifications</h2>
|
||||
<ul>
|
||||
${certifications.map(c => `<li>${escapeText(c.name)}${c.issuer ? ` - ${escapeText(c.issuer)}` : ''}${c.date ? ` (${escapeText(c.date)})` : ''}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Printable CV</title>
|
||||
${styles}
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
${headerHtml}
|
||||
${summarySection}
|
||||
${workSection}
|
||||
${eduSection}
|
||||
${skillsSection}
|
||||
${languagesSection}
|
||||
${certsSection}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user