├── screencast.gif ├── app-screencast.gif ├── .npmignore ├── app ├── assets │ ├── kiss-my-resume.icns │ ├── kiss-my-resume.ico │ └── kiss-my-resume.png ├── blacklisted-themes.json ├── renderer │ ├── components │ │ └── App │ │ │ ├── App.css │ │ │ └── App.tsx │ ├── hooks │ │ └── useThemeList.tsx │ └── renderer.tsx ├── index.html ├── index.css ├── bootstrap-override.css ├── preload.ts ├── definitions.ts └── main │ ├── preview.ts │ ├── index.ts │ ├── theme-helpers.ts │ └── ipc-event-listeners.ts ├── declarations.d.ts ├── webpack.main.config.js ├── tsconfig.json ├── .eslintrc.json ├── webpack.rules.js ├── lib ├── convert.js ├── log.js ├── parse.js ├── validate.js ├── serve.js ├── build.js └── cli.js ├── LICENSE ├── webpack.renderer.config.js ├── .gitignore ├── resume └── empty-json-resume.json ├── webpack.plugins.js ├── package.json ├── README.md └── schemes ├── json-resume-schema_0.0.0.json └── fresh-resume-schema_1.0.0-beta.json /screencast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlitos/KissMyResume/HEAD/screencast.gif -------------------------------------------------------------------------------- /app-screencast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlitos/KissMyResume/HEAD/app-screencast.gif -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | resume.html 4 | resume.pdf 5 | resume.json 6 | .DS_Store 7 | .idea 8 | -------------------------------------------------------------------------------- /app/assets/kiss-my-resume.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlitos/KissMyResume/HEAD/app/assets/kiss-my-resume.icns -------------------------------------------------------------------------------- /app/assets/kiss-my-resume.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlitos/KissMyResume/HEAD/app/assets/kiss-my-resume.ico -------------------------------------------------------------------------------- /app/assets/kiss-my-resume.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlitos/KissMyResume/HEAD/app/assets/kiss-my-resume.png -------------------------------------------------------------------------------- /app/blacklisted-themes.json: -------------------------------------------------------------------------------- 1 | [ 2 | "jsonresume-theme-moon", 3 | "jsonresume-theme-briefstrap", 4 | "jsonresume-theme-elite", 5 | "jsonresume-theme-qimia" 6 | ] 7 | -------------------------------------------------------------------------------- /app/renderer/components/App/App.css: -------------------------------------------------------------------------------- 1 | .notification-area { 2 | height: 3.5em; 3 | overflow-y: auto; 4 | border: 1px ridge #ccc; 5 | border-radius: 4px; 6 | } 7 | -------------------------------------------------------------------------------- /declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css' { 2 | const styles: { [key: string]: string }; 3 | export default styles; 4 | } 5 | 6 | declare module '*.svg' { 7 | const content: string; 8 | export default content; 9 | } 10 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Kiss My Resume 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /app/index.css: -------------------------------------------------------------------------------- 1 | @import './bootstrap-override.css'; 2 | 3 | body { 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif !important; 5 | margin: auto !important; 6 | padding: 2rem !important; 7 | } 8 | 9 | /* Some generic global classes/helpers */ 10 | .float-left { 11 | float: left; 12 | } 13 | 14 | .float-right { 15 | float: right; 16 | } 17 | -------------------------------------------------------------------------------- /app/bootstrap-override.css: -------------------------------------------------------------------------------- 1 | /* Globally overrides bootstrap classes */ 2 | 3 | .alert-slim { /* Less margin and vertical padding */ 4 | margin: 5px; 5 | padding: 5px 15px; 6 | } 7 | 8 | .row-no-gutter { 9 | margin-right: 0; 10 | margin-left: 0; 11 | } 12 | 13 | .form-control { 14 | height: 36px; 15 | } 16 | 17 | .btn .caret, .dropdown-menu > li > .checkbox { 18 | margin-left: 1em; 19 | } 20 | -------------------------------------------------------------------------------- /app/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from 'electron'; 2 | import { VALID_INVOKE_CHANNELS } from './definitions'; 3 | 4 | // see https://stackoverflow.com/a/59888788/1991697 5 | contextBridge.exposeInMainWorld('api', { 6 | invoke: (chanel: string, ...data: any[]) => { 7 | if (chanel in VALID_INVOKE_CHANNELS) { 8 | return ipcRenderer.invoke(chanel, ...data); 9 | } 10 | return Promise.reject('Invoke not allowed'); 11 | } 12 | } 13 | ); 14 | -------------------------------------------------------------------------------- /webpack.main.config.js: -------------------------------------------------------------------------------- 1 | const plugins = require('./webpack.plugins'); 2 | 3 | module.exports = { 4 | /** 5 | * This is the main entry point for your application, it's the first file 6 | * that runs in the main process. 7 | */ 8 | entry: './app/main/index.ts', 9 | // Put your normal webpack config below here 10 | module: { 11 | rules: require('./webpack.rules'), 12 | }, 13 | plugins: [plugins.copyPlugin,], 14 | resolve: { 15 | extensions: ['.js', '.ts', '.jsx', '.tsx', '.css', '.json'] 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "module": "commonjs", 5 | "skipLibCheck": true, 6 | "esModuleInterop": true, 7 | "noImplicitAny": true, 8 | "sourceMap": true, 9 | "baseUrl": ".", 10 | "outDir": "dist", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "paths": { 14 | "*": ["node_modules/*"] 15 | }, 16 | "jsx": "react" 17 | }, 18 | "include": [ 19 | "app/**/*" 20 | ], 21 | "files": [ 22 | "declarations.d.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:import/errors", 12 | "plugin:import/warnings" 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "settings": { 16 | "import/resolver": { 17 | "node": { 18 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 19 | } 20 | } 21 | }, 22 | "rules": { 23 | "object-curly-spacing": ["error", "always"] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /webpack.rules.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | // Add support for native node modules 3 | { 4 | test: /\.node$/, 5 | use: 'node-loader', 6 | }, 7 | /* 8 | { 9 | test: /\.(m?js|node)$/, 10 | parser: { amd: false }, 11 | use: { 12 | loader: '@marshallofsound/webpack-asset-relocator-loader', 13 | options: { 14 | outputAssetBase: 'native_modules', 15 | }, 16 | }, 17 | }, 18 | */ 19 | { 20 | test: /\.tsx?$/, 21 | exclude: /(node_modules|\.webpack)/, 22 | use: { 23 | loader: 'ts-loader', 24 | options: { 25 | transpileOnly: true 26 | } 27 | } 28 | }, 29 | ]; 30 | -------------------------------------------------------------------------------- /app/definitions.ts: -------------------------------------------------------------------------------- 1 | // Fixes the typescript errors cause by having the api property on the window object 2 | declare global { 3 | interface Window { 4 | api: Record; 5 | } 6 | } 7 | 8 | // The enum keys and values has to be same, otherwise the reverse lookup and the 'in' operator won't work 9 | export enum VALID_INVOKE_CHANNELS { 10 | 'open-cv' = 'open-cv', 11 | 'process-cv' = 'process-cv', 12 | 'save-cv' = 'save-cv', 13 | 'get-theme-list' = 'get-theme-list', 14 | 'fetch-theme' = 'fetch-theme', 15 | } 16 | 17 | export interface INotification { 18 | type: 'success' | 'info' | 'warning' | 'danger'; 19 | text: string; 20 | } 21 | 22 | export interface IThemeEntry { 23 | name: string , 24 | description: string, 25 | version: string, 26 | downloadLink: string, 27 | present: boolean, 28 | } 29 | -------------------------------------------------------------------------------- /lib/convert.js: -------------------------------------------------------------------------------- 1 | const converter = require('fresh-jrs-converter'); 2 | const { getResumeType, RESUME_TYPE_JSON, RESUME_TYPE_FRESH, RESUME_TYPE_UNKNOWN } = require('./validate'); 3 | 4 | const convertToJsonResume = (resume) => { 5 | const resumeType = getResumeType(resume); 6 | if ( resumeType === RESUME_TYPE_JSON || resumeType === RESUME_TYPE_UNKNOWN ) { 7 | throw new Error('Cannot convert Json-resume or unsupported resume type to Json-resume!') 8 | } 9 | return converter.toJSR(resume); 10 | }; 11 | 12 | const convertToFreshResume = (resume) => { 13 | const resumeType = getResumeType(resume); 14 | if ( resumeType === RESUME_TYPE_FRESH || resumeType === RESUME_TYPE_UNKNOWN ) { 15 | throw new Error('Cannot convert FRESH resume or unsupported resume type to FRESH resume!') 16 | } 17 | return converter.toFRESH(resume); 18 | }; 19 | 20 | module.exports = { 21 | convertToJsonResume, 22 | convertToFreshResume, 23 | }; 24 | -------------------------------------------------------------------------------- /app/main/preview.ts: -------------------------------------------------------------------------------- 1 | export const PREVIEW_DEFAULT_MARKUP =` 2 | 3 | 4 | 5 | 6 | 29 | 30 | 31 | 32 |
preview
33 | 34 | 35 | `; 36 | -------------------------------------------------------------------------------- /app/renderer/hooks/useThemeList.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, Dispatch, SetStateAction } from 'react'; 2 | import {INotification, IThemeEntry, VALID_INVOKE_CHANNELS} from "../../definitions"; 3 | 4 | export const useThemeList = (): [INotification, IThemeEntry[], Dispatch>] => { 5 | const [themeList, setThemeList] = useState([]); 6 | let err: INotification = null; 7 | 8 | useEffect(() => { 9 | const fetchThemeList = async () => { 10 | try { 11 | // set result as theme list 12 | setThemeList( await window.api.invoke(VALID_INVOKE_CHANNELS['get-theme-list'])); 13 | } catch (e) { 14 | // pass the exception message as a warning-type notification 15 | err = { type: 'warning', text: e.message } 16 | 17 | } 18 | }; 19 | // calling async functions in useEffect prevents warnings, see: https://www.robinwieruch.de/react-hooks-fetch-data 20 | fetchThemeList(); 21 | }, []); 22 | 23 | return [err, themeList, setThemeList]; 24 | }; 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Karel Mácha 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/log.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const log = console.log; 3 | const error = console.error; 4 | 5 | let includeStacktrace = false; 6 | let errorCounter = 0; 7 | 8 | /** 9 | * Chalk wrapper for console info output 10 | * @param text {String} The text to be logged 11 | */ 12 | const logInfo = (text) => log(chalk.white(`\n${text}`)); 13 | 14 | /** 15 | * Chalk wrapper for console success output 16 | * @param text {String} The text to be logged 17 | */ 18 | const logSuccess = (text) => { 19 | log(chalk.green(`\n${text}`)); 20 | } 21 | 22 | /** 23 | * Chalk wrapper for console error output 24 | * @param text {String} The text to be logged 25 | */ 26 | const logError = (err) => { 27 | 28 | errorCounter ++; 29 | const debugOutput = includeStacktrace && err.stack ? `\nReason: ${err.stack}` : ''; 30 | error(chalk.red(`\n${err}${debugOutput}`)); 31 | }; 32 | 33 | /** 34 | * Chalk wrapper for console server output 35 | * @param text {String} The text to be logged 36 | */ 37 | const logServer = (text) => log(chalk.blue(`\n${text}`)); 38 | 39 | module.exports = { 40 | logInfo, 41 | logSuccess, 42 | logError, 43 | logServer, 44 | setLogLevelToDebug: () => includeStacktrace = true, 45 | getErrorCount: () => errorCounter 46 | }; -------------------------------------------------------------------------------- /webpack.renderer.config.js: -------------------------------------------------------------------------------- 1 | const rules = require('./webpack.rules'); 2 | const plugins = require('./webpack.plugins'); 3 | 4 | rules.push( 5 | { // see: https://github.com/css-modules/css-modules/pull/65#issuecomment-354712147 6 | test: /\.css$/, 7 | oneOf: [ 8 | { 9 | resourceQuery: /^\?raw$/, 10 | use: [{ loader: 'style-loader' }, { loader: 'css-loader',}] 11 | }, 12 | { 13 | test: /\.css$/, 14 | use: [ 15 | { loader: 'style-loader' }, 16 | { loader: 'css-loader', options: { modules: true }} 17 | ], 18 | }, 19 | ] 20 | }, 21 | { // see: https://chriscourses.com/blog/loading-fonts-webpack 22 | test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/, 23 | use: [{ 24 | loader: 'file-loader', 25 | options: { 26 | name: '[name].[ext]', 27 | outputPath: 'fonts/' 28 | } 29 | }] 30 | } 31 | ); 32 | 33 | module.exports = { 34 | module: { 35 | rules, 36 | }, 37 | output: { 38 | publicPath: './../', 39 | }, 40 | plugins: [plugins.forkTsCheckerWebpackPlugin, plugins.optimizeCssnanoPlugin, plugins.provideJqueryPlugin], 41 | resolve: { 42 | extensions: ['.js', '.ts', '.jsx', '.tsx', '.css'] 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /app/renderer/renderer.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This file will automatically be loaded by webpack and run in the "renderer" context. 3 | * To learn more about the differences between the "main" and the "renderer" context in 4 | * Electron, visit: 5 | * 6 | * https://electronjs.org/docs/tutorial/application-architecture#main-and-renderer-processes 7 | * 8 | * By default, Node.js integration in this file is disabled. When enabling Node.js integration 9 | * in a renderer process, please be aware of potential security implications. You can read 10 | * more about security risks here: 11 | * 12 | * https://electronjs.org/docs/tutorial/security 13 | * 14 | * To enable Node.js integration in this file, open up `main.js` and enable the `nodeIntegration` 15 | * flag: 16 | * 17 | * ``` 18 | * // Create the browser window. 19 | * mainWindow = new BrowserWindow({ 20 | * width: 800, 21 | * height: 600, 22 | * webPreferences: { 23 | * nodeIntegration: true 24 | * } 25 | * }); 26 | * ``` 27 | */ 28 | 29 | import '../index.css?raw'; 30 | // Don't change the order, override has to come after the main bootstrap 31 | import 'bootstrap3/dist/js/bootstrap'; 32 | import 'bootstrap3/dist/css/bootstrap.min.css?raw'; 33 | import '../bootstrap-override.css?raw'; 34 | import 'ez-space-css/css/ez-space.css?raw'; 35 | 36 | import React from 'react'; 37 | import ReactDOM from 'react-dom'; 38 | 39 | import App from './components/App/App'; 40 | 41 | ReactDOM.render(, document.querySelector('#root')); 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | resume.html 4 | resume.pdf 5 | resume.json 6 | .DS_Store 7 | .idea 8 | .vscode 9 | .npmrc 10 | resume out/ 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | lerna-debug.log* 19 | 20 | # Diagnostic reports (https://nodejs.org/api/report.html) 21 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 22 | 23 | # Runtime data 24 | pids 25 | *.pid 26 | *.seed 27 | *.pid.lock 28 | .DS_Store 29 | 30 | # Directory for instrumented libs generated by jscoverage/JSCover 31 | lib-cov 32 | 33 | # Coverage directory used by tools like istanbul 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | .nyc_output 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | jspm_packages/ 49 | 50 | # TypeScript v1 declaration files 51 | typings/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # next.js build output 79 | .next 80 | 81 | # nuxt.js build output 82 | .nuxt 83 | 84 | # vuepress build output 85 | .vuepress/dist 86 | 87 | # Serverless directories 88 | .serverless/ 89 | 90 | # FuseBox cache 91 | .fusebox/ 92 | 93 | # DynamoDB Local files 94 | .dynamodb/ 95 | 96 | # Webpack 97 | .webpack/ 98 | 99 | # Electron-Forge 100 | out/ 101 | -------------------------------------------------------------------------------- /lib/parse.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const { logInfo, logSuccess } = require('./log'); 4 | const { getResumeType, RESUME_TYPE_JSON, RESUME_TYPE_FRESH, RESUME_TYPE_UNKNOWN } = require('./validate'); 5 | 6 | /** 7 | * Parse the source JSON file and do some type checking 8 | * @param sourcePath {string} Source path to the resume to be parsed 9 | * @param logging {boolean} Whether the method should do any logging or not 10 | * @returns {{resume: Object, type: (string|*)}} Returns an object containing the parsed resume and its type 11 | */ 12 | const parseResumeFromSource = (sourcePath, logging = true) => { 13 | // do some logging 14 | if (logging) logInfo(`Parsing resume file from ${sourcePath}`); 15 | 16 | try { 17 | const resume = JSON.parse(fs.readFileSync(sourcePath)); 18 | return parseResume(resume, logging) 19 | } catch (err) { 20 | throw new Error(`There was an problem when loading ${sourcePath}. Reason: ${err}.`); 21 | } 22 | }; 23 | 24 | /** 25 | * Parse resume data from an object 26 | * @param resume {Object} The resume data as a object 27 | * @param logging {boolean} Whether the method should do any logging or not 28 | * @returns {{resume: Object, type: (string|*)}} Returns an object containing the parsed resume and its type 29 | */ 30 | const parseResume = (resume, logging = true) => { 31 | try { 32 | // // Do some validation 33 | const type = getResumeType(resume); 34 | switch (type) { 35 | case RESUME_TYPE_JSON: 36 | if (logging) logSuccess('Succesfully parsed resume in JSON-Resume format.'); 37 | break; 38 | case RESUME_TYPE_FRESH: 39 | if (logging) logSuccess('Succesfully parsed resume in FRESH format.'); 40 | break; 41 | case RESUME_TYPE_UNKNOWN: 42 | default: 43 | throw new Error(`Invalid or unknown resume format detected!`); 44 | } 45 | return { resume, type, }; 46 | } catch (err) { 47 | throw new Error(`There was an problem when parsing ${resume}. Reason: ${err}.`); 48 | } 49 | }; 50 | 51 | module.exports = { 52 | parseResumeFromSource, 53 | parseResume, 54 | }; 55 | -------------------------------------------------------------------------------- /resume/empty-json-resume.json: -------------------------------------------------------------------------------- 1 | { 2 | "basics": { 3 | "name": "", 4 | "label": "", 5 | "picture": "", 6 | "email": "", 7 | "phone": "", 8 | "website": "", 9 | "summary": "", 10 | "location": { 11 | "address": "", 12 | "postalCode": "", 13 | "city": "", 14 | "countryCode": "", 15 | "region": "" 16 | }, 17 | "profiles": [ 18 | { 19 | "network": "", 20 | "username": "", 21 | "url": "" 22 | } 23 | ] 24 | }, 25 | "work": [ 26 | { 27 | "company": "", 28 | "position": "", 29 | "website": "", 30 | "startDate": "", 31 | "endDate": "", 32 | "summary": "", 33 | "highlights": [ 34 | "", 35 | "", 36 | "" 37 | ] 38 | } 39 | ], 40 | "volunteer": [ 41 | { 42 | "organization": "", 43 | "position": "", 44 | "website": "", 45 | "startDate": "", 46 | "endDate": "", 47 | "summary": "", 48 | "highlights": [ 49 | "", 50 | "", 51 | "" 52 | ] 53 | } 54 | ], 55 | "education": [ 56 | { 57 | "institution": "", 58 | "area": "", 59 | "studyType": "", 60 | "startDate": "", 61 | "endDate": "", 62 | "gpa": "", 63 | "courses": [ 64 | "", 65 | "", 66 | "" 67 | ] 68 | } 69 | ], 70 | "awards": [ 71 | { 72 | "title": "", 73 | "date": "", 74 | "awarder": "", 75 | "summary": "" 76 | } 77 | ], 78 | "publications": [ 79 | { 80 | "name": "", 81 | "publisher": "", 82 | "releaseDate": "", 83 | "website": "", 84 | "summary": "" 85 | } 86 | ], 87 | "skills": [ 88 | { 89 | "name": "", 90 | "level": "", 91 | "keywords": [ 92 | "", 93 | "", 94 | "" 95 | ] 96 | }, 97 | { 98 | "name": "", 99 | "level": "", 100 | "keywords": [ 101 | "", 102 | "", 103 | "" 104 | ] 105 | } 106 | ], 107 | "languages": [ 108 | { 109 | "language": "", 110 | "fluency": "" 111 | } 112 | ], 113 | "interests": [ 114 | { 115 | "name": "", 116 | "keywords": [ 117 | "", 118 | "" 119 | ] 120 | } 121 | ], 122 | "references": [ 123 | { 124 | "name": "", 125 | "reference": "" 126 | } 127 | ] 128 | } 129 | -------------------------------------------------------------------------------- /lib/validate.js: -------------------------------------------------------------------------------- 1 | const jsonResumeSchema = require('../schemes/json-resume-schema_0.0.0'); 2 | const freshResumeSchema = require('../schemes/fresh-resume-schema_1.0.0-beta'); 3 | const ZSchema = require('z-schema'); 4 | const { logInfo, logSuccess, logError } = require('./log'); 5 | 6 | // TODO: consider normalizing error messages from Z-Schema 7 | // https://github.com/dschenkelman/z-schema-errors 8 | 9 | // Constants identifying the different resume types 10 | const RESUME_TYPE_JSON = 'jrs'; 11 | const RESUME_TYPE_FRESH = 'fresh'; 12 | const RESUME_TYPE_UNKNOWN = 'unk'; 13 | 14 | // Instantiate new Z-schema validator 15 | const validator = new ZSchema({ breakOnFirstError: false }); 16 | 17 | /** 18 | * Method determining the type of parsed resume 19 | * @param resume {Object} The parsed resume 20 | * @returns {(RESUME_TYPE_JSON|RESUME_TYPE_FRESH|RESUME_TYPE_UNKNOWN)} The type of the resume 21 | */ 22 | const getResumeType = (resume) => { 23 | if (resume.meta && resume.meta.format) { //&& resume.meta.format.substr(0, 5).toUpperCase() == 'FRESH' 24 | return RESUME_TYPE_FRESH; 25 | } else if (resume.basics) { 26 | return RESUME_TYPE_JSON; 27 | } else { 28 | return RESUME_TYPE_UNKNOWN; 29 | } 30 | }; 31 | 32 | /** 33 | * Validates resume in Json-resume format. Logs an success message in case of a valid resume or a list of validation 34 | * errors otherwise 35 | * @param resume {Object} The parsed resume 36 | */ 37 | const validateJsonResume = (resume) => { 38 | const valid = validator.validate(resume, jsonResumeSchema); 39 | if (!valid) { 40 | logInfo('--- Your resume contains errors ---'); 41 | for (const validationError of validator.getLastErrors()) { 42 | logError(`# ${validationError.message} in ${validationError.path}`); 43 | } 44 | } else { 45 | logSuccess('Valid resume in Json-resume format.') 46 | } 47 | 48 | }; 49 | 50 | /** 51 | * Validates resume in FRESH format. Logs an success message in case of a valid resume or a list of validation 52 | * errors otherwise 53 | * @param resume {Object} The parsed resume 54 | */ 55 | const validateFreshResume = (resume) => { 56 | const valid = validator.validate(resume, freshResumeSchema); 57 | if (!valid) { 58 | logInfo('--- Your resume contains errors ---'); 59 | for (const validationError of validator.getLastErrors()) { 60 | logError(`# ${validationError.message} in ${validationError.path}`); 61 | } 62 | } else { 63 | logSuccess('Valid resume in FRESH format.') 64 | } 65 | }; 66 | 67 | 68 | module.exports = { 69 | RESUME_TYPE_JSON, 70 | RESUME_TYPE_FRESH, 71 | RESUME_TYPE_UNKNOWN, 72 | getResumeType, 73 | validateResume: (resume, type) => { 74 | if (type === RESUME_TYPE_JSON) { 75 | validateJsonResume(resume); 76 | } else if (type === RESUME_TYPE_FRESH) { 77 | validateFreshResume(resume) 78 | } else { 79 | throw new Error('Unsupported resume type!') 80 | } 81 | }, 82 | }; 83 | -------------------------------------------------------------------------------- /webpack.plugins.js: -------------------------------------------------------------------------------- 1 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 2 | const OptimizeCssnanoPlugin = require('@intervolga/optimize-cssnano-plugin'); 3 | const CopyPlugin = require('copy-webpack-plugin'); 4 | const { DefinePlugin, ProvidePlugin } = require('webpack'); 5 | 6 | const path = require('path'); 7 | // const puppeteer = require('puppeteer'); 8 | // const exec = require('child_process').exec; 9 | 10 | /** 11 | * The copyPlugin is used to add some necessary assets to the final bundle. The downloading and bundling puppeteer was 12 | * a way to overcome the limitations of electron and puppeteer-in-electron, but resulted in a huge app with several 13 | * hundred MB size. After a puppeteer-independent solution for the PDF/PNG export was found the puppeteer the 14 | * .local-chromium instance does not need to be included any more, the plugin-setup will be left here first for 15 | * reference. 16 | */ 17 | module.exports = { 18 | forkTsCheckerWebpackPlugin: new ForkTsCheckerWebpackPlugin(), 19 | optimizeCssnanoPlugin: new OptimizeCssnanoPlugin({}), 20 | provideJqueryPlugin: new ProvidePlugin({ 21 | $: 'jquery', 22 | jquery: 'jquery', 23 | 'window.jQuery': 'jquery', 24 | jQuery:'jquery' 25 | }), 26 | copyPlugin: new CopyPlugin({ 27 | patterns: [ 28 | // This fix missing assets for html-docx-js 29 | { 30 | from: path.resolve(__dirname, 'node_modules/html-docx-js/build/assets'), 31 | to: path.resolve(__dirname, '.webpack/main/assets'), 32 | }, 33 | // This fix missing styling and template for jsonresume-theme-flat 34 | { 35 | from: path.resolve(__dirname, 'node_modules/jsonresume-theme-flat/style.css'), 36 | to: path.resolve(__dirname, '.webpack/main/'), 37 | }, 38 | { 39 | from: path.resolve(__dirname, 'node_modules/jsonresume-theme-flat/resume.template'), 40 | to: path.resolve(__dirname, '.webpack/main/'), 41 | }, 42 | /* 43 | { 44 | from: path.resolve(__dirname, puppeteer.executablePath().split('/.local-chromium')[0], '.local-chromium'), 45 | to: path.resolve(__dirname, '.webpack/main/chromium'), 46 | globOptions: { 47 | followSymbolicLinks: false, 48 | } 49 | }, 50 | */ 51 | ], 52 | }), 53 | runShellAfterEmitPlugin: { 54 | /* 55 | apply: (compiler) => { 56 | compiler.hooks.afterEmit.tap('AfterEmitPlugin', (compilation) => { 57 | exec(`chmod -R 755 ${path.resolve(__dirname, '.webpack/main/chromium/')}`, (err, stdout, stderr) => { 58 | if (stdout) process.stdout.write(stdout); 59 | if (stderr) process.stderr.write(stderr); 60 | }); 61 | }); 62 | }, 63 | */ 64 | }, 65 | definePlugin: new DefinePlugin({ 66 | // CHROMIUM_BINARY: JSON.stringify(path.join('chromium', puppeteer.executablePath().split('/.local-chromium/')[1])), 67 | }), 68 | }; 69 | -------------------------------------------------------------------------------- /lib/serve.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const reload = require('reload'); 5 | const open = require('open'); 6 | 7 | /** 8 | * The port, the webserver will be listening on 9 | */ 10 | const DEFAULT_PORT = 3000; 11 | /** 12 | * The polling rate with which the watcher will be looking for resume changes 13 | */ 14 | const POLLING_RATE = 2000; 15 | /** 16 | * server The server instance created with the express framework. 17 | */ 18 | let server; 19 | let markup; 20 | 21 | const { logInfo, logSuccess, logServer } = require('./log'); 22 | const { createMarkupFromSource } = require('./build'); 23 | 24 | const app = express(); 25 | 26 | const serveResume = async (sourcePath, theme, port = DEFAULT_PORT) => { 27 | try { 28 | // Try to generate the markup in the first place to see whether to continue 29 | markup = await createMarkupFromSource(sourcePath, theme, false); 30 | // Set up the port 31 | app.set('port', port); 32 | // Set up the main path 33 | app.get('/', async (req, res) => { 34 | // Do not create the markup when it already exist (for whatever reason) 35 | // TODO: improve (fix) error handling when createMarkupFromSource throws an error 36 | markup = !!markup ? markup : await createMarkupFromSource(sourcePath, theme, false); 37 | // Add the script tag with the replace javascript link to the end of the Html body to enable hot-reloading 38 | res.send(markup.replace(/(<\/body>)/i, '\n')); 39 | // Get sure the markup will be created next time 40 | markup = null; 41 | }); 42 | 43 | const reloadServer = await reload(app); 44 | // Reload started, start web server 45 | server = app.listen(port, async () => { 46 | logServer(`You can view your resume in Webbrowser on address: ${url}`); 47 | }); 48 | 49 | // Set up the resume watcher 50 | fs.watchFile(sourcePath, { interval: POLLING_RATE }, (curr, prev) => { 51 | logServer(`Resume ${path.basename(sourcePath)} change detected - reloading`); 52 | reloadServer.reload(); 53 | }); 54 | 55 | // Opens the url in the default browser 56 | const url = `http://www.localhost:${port}`; 57 | await open(url); 58 | logInfo('Resume opened in the default browser.') 59 | } catch (err) { 60 | // Handle error 61 | throw new Error(`Problem when (re)loading the Webserver, serving the resume: ${err}`); 62 | } 63 | }; 64 | 65 | /** 66 | * Stops the server serving the resume. 67 | */ 68 | const stopServingResume = () => { 69 | return new Promise((resolve, reject) => { 70 | if (!server) { 71 | reject('The server is not running and thus could not be stopped'); 72 | return; 73 | } 74 | // stop the server 75 | server.close(() => { 76 | // TODO: Server close is not sufficient - see https://stackoverflow.com/questions/14626636/how-do-i-shutdown-a-node-js-https-server-immediately 77 | resolve('The server serving the resume stopped successfully!'); 78 | }); 79 | }); 80 | }; 81 | 82 | module.exports = { 83 | DEFAULT_PORT, 84 | serveResume, 85 | stopServingResume 86 | }; 87 | -------------------------------------------------------------------------------- /app/main/index.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserView, BrowserWindow, ipcMain, screen } from 'electron'; 2 | import { VALID_INVOKE_CHANNELS } from '../definitions'; 3 | import { PREVIEW_DEFAULT_MARKUP } from './preview' 4 | import { 5 | fetchThemeListener, 6 | getThemeListListener, 7 | openCvListener, 8 | processCvListener, 9 | saveCvListener, 10 | } from './ipc-event-listeners'; 11 | 12 | declare const MAIN_WINDOW_WEBPACK_ENTRY: any, MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: any; 13 | 14 | // Comment our to see security warnings! 15 | // Further reading https://github.com/electron/electron/issues/19775 16 | process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true'; 17 | 18 | // Handle creating/removing shortcuts on Windows when installing/uninstalling. 19 | if (require('electron-squirrel-startup')) { // eslint-disable-line global-require 20 | app.quit(); 21 | } 22 | 23 | const createWindow = async () => { 24 | const { width, height } = screen.getPrimaryDisplay().workAreaSize; 25 | // Create the browser window. 26 | const mainWindow = new BrowserWindow({ 27 | height, 28 | width, 29 | minHeight: 600, 30 | minWidth: 800, 31 | webPreferences: { 32 | nodeIntegration: false, // is default value after Electron v5 33 | contextIsolation: true, // protect against prototype pollution 34 | enableRemoteModule: false, // turn off remote 35 | } 36 | }); 37 | 38 | // BrowserView for the resume-form 39 | const form = new BrowserView({ 40 | webPreferences: { 41 | nodeIntegration: false, // is default value after Electron v5 42 | contextIsolation: true, // protect against prototype pollution 43 | enableRemoteModule: false, // turn off remote 44 | preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY, // see https://www.electronforge.io/config/plugins/webpack#project-setup 45 | } 46 | }); 47 | mainWindow.addBrowserView(form); 48 | form.setBounds({ x: 0, y: 0, width: width/2, height }); 49 | form.setAutoResize({ width: true, height: true, horizontal: true, vertical: true }); 50 | // load the index.html of the app in the form-BrowserView 51 | form.webContents.loadURL(MAIN_WINDOW_WEBPACK_ENTRY/*, 52 | { 53 | postData: [{ 54 | type: 'rawData', 55 | bytes: Buffer.from('hello=world') 56 | }], 57 | extraHeaders: 'Content-Type: application/x-www-form-urlencoded' 58 | } 59 | */ 60 | ); 61 | // form.webContents.openDevTools({ mode: 'undocked' }); 62 | 63 | // BrowserView for the preview 64 | const preview = new BrowserView(); 65 | mainWindow.addBrowserView(preview); 66 | preview.setBounds({ x: width/2, y: 0, width: width/2, height }); 67 | preview.setAutoResize({ width: true, height: true, horizontal: true, vertical: true }); 68 | preview.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(PREVIEW_DEFAULT_MARKUP)}`); 69 | // preview.webContents.openDevTools({ mode: 'undocked' }); 70 | mainWindow.maximize(); 71 | }; 72 | 73 | // This method will be called when Electron has finished 74 | // initialization and is ready to create browser windows. 75 | // Some APIs can only be used after this event occurs. 76 | app.on('ready', createWindow); 77 | 78 | // Quit when all windows are closed, except on macOS. There, it's common 79 | // for applications and their menu bar to stay active until the user quits 80 | // explicitly with Cmd + Q. 81 | app.on('window-all-closed', () => { 82 | if (process.platform !== 'darwin') { 83 | app.quit(); 84 | } 85 | }); 86 | 87 | app.on('activate', () => { 88 | // On OS X it's common to re-create a window in the app when the 89 | // dock icon is clicked and there are no other windows open. 90 | if (BrowserWindow.getAllWindows().length === 0) { 91 | createWindow(); 92 | } 93 | }); 94 | 95 | // In this file you can include the rest of your app's specific main process 96 | // code. You can also put them in separate files and import them here. 97 | ipcMain.handle(VALID_INVOKE_CHANNELS['open-cv'], openCvListener); 98 | 99 | ipcMain.handle(VALID_INVOKE_CHANNELS['save-cv'], saveCvListener); 100 | 101 | ipcMain.handle(VALID_INVOKE_CHANNELS['process-cv'], processCvListener); 102 | 103 | ipcMain.handle(VALID_INVOKE_CHANNELS['get-theme-list'], getThemeListListener); 104 | 105 | ipcMain.handle(VALID_INVOKE_CHANNELS['fetch-theme'], fetchThemeListener); 106 | -------------------------------------------------------------------------------- /app/main/theme-helpers.ts: -------------------------------------------------------------------------------- 1 | import got from 'got'; 2 | import * as path from 'path'; 3 | import * as fs from 'fs'; 4 | // @ts-ignore 5 | import { app } from 'electron'; 6 | import { IThemeEntry } from '../definitions'; 7 | // @ts-ignore 8 | import { PluginManager } from 'live-plugin-manager'; 9 | // @ts-ignore 10 | import DEFAULT_THEME from 'jsonresume-theme-flat'; 11 | export const DEFUALT_THEME_NAME = 'jsonresume-theme-flat'; 12 | 13 | const blacklistedThemes = require('../blacklisted-themes.json'); 14 | 15 | const NPM_REGISTRY_URL = 'https://registry.npmjs.org/'; 16 | const NPM_SEARCH_QUERY = 'jsonresume-theme-'; 17 | const NPM_SEARCH_SIZE = 250; 18 | 19 | const localThemesPath = path.resolve(app.getPath('appData'), app.getName(), 'themes'); 20 | const pluginManager = new PluginManager({ 21 | pluginsPath: localThemesPath, 22 | staticDependencies: { 23 | fs: require('fs'), 24 | path: require('path'), 25 | util: require('util'), 26 | os: require('os'), 27 | events: require('events'), 28 | assert: require('assert'), 29 | http: require('http'), 30 | https: require('https'), 31 | url: require('url'), 32 | } 33 | }); 34 | 35 | 36 | /** 37 | * For NPM API docs see https://api-docs.npms.io/ 38 | */ 39 | export const getThemeList = async () => { 40 | try { 41 | const response: Record = await got(`${NPM_REGISTRY_URL}-/v1/search`, 42 | { 43 | searchParams: { 44 | text: `${NPM_SEARCH_QUERY}-*`, 45 | size: NPM_SEARCH_SIZE, 46 | }, 47 | }).json(); 48 | 49 | let localThemesList: string[] = []; 50 | if (fs.existsSync(localThemesPath)) { 51 | // update the localThemesList value 52 | localThemesList = fs.readdirSync(localThemesPath, { withFileTypes: true }) 53 | .filter(dir => dir.isDirectory() && dir.name.includes(NPM_SEARCH_QUERY) ).map(dir => dir.name); 54 | } else { // create the theme directory when not present 55 | fs.mkdirSync(localThemesPath); 56 | } 57 | 58 | // return mapped results or empty array 59 | const themeList = !!response && !!response.objects ? response.objects.reduce((result: Array, pkg: Record) => { 60 | if (pkg.package.name.includes(NPM_SEARCH_QUERY) && !blacklistedThemes.includes(pkg.package.name)) { 61 | result.push( { 62 | name: pkg.package.name, 63 | description: pkg.package.description, 64 | version: pkg.package.version, 65 | downloadLink: `${NPM_REGISTRY_URL}${pkg.package.name}/-/${pkg.package.name}-${pkg.package.version}.tgz`, 66 | present: pkg.package.name === DEFUALT_THEME_NAME || localThemesList.includes(pkg.package.name) ? true : false, 67 | }); 68 | } 69 | return result; 70 | }, []) : []; 71 | // return a resolved promise 72 | return Promise.resolve(themeList) 73 | } catch (err) { 74 | // in case of an error return a rejected Promise 75 | return Promise.reject(err); 76 | } 77 | }; 78 | 79 | /** 80 | * This is the method responsible for the actual theme retrieval. We retrieve the theme - actual a NPM package - with 81 | * help of the 'live-plugin-manager'. The pluginManager.install call fetch the theme with all its dependencies from NPM. 82 | * @param theme {IThemeEntry} The theme which should be fetched from NPM - we use the name-property as identifier. 83 | */ 84 | export const fetchTheme = async (theme: IThemeEntry) => { 85 | try { 86 | await pluginManager.install(theme.name); 87 | } catch (err) { 88 | return Promise.reject(err) 89 | } 90 | }; 91 | 92 | /** 93 | * 94 | */ 95 | export const getLocalTheme = async (theme: IThemeEntry) => { 96 | try { 97 | if (!!theme && theme.name !== DEFUALT_THEME_NAME) { 98 | // Wee need to call the install method even for cached packages, so the require method works whenever we 99 | // switch to a new theme. See https://github.com/davideicardi/live-plugin-manager/issues/18 100 | await pluginManager.install(theme.name); 101 | return await pluginManager.require(theme.name); 102 | } 103 | // return default theme when no theme specified 104 | return DEFAULT_THEME; 105 | } catch (err) { 106 | return Promise.reject(err) 107 | } 108 | }; 109 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": { 3 | "name": "Karel Mácha", 4 | "email": "karel.macha@karlitos.net", 5 | "url": "http://karlitos.net/" 6 | }, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/karlitos/KissMyResume" 10 | }, 11 | "name": "kiss-my-resume", 12 | "productName": "kiss-my-resume", 13 | "version": "1.1.0-beta.1", 14 | "description": "KissMyResume is a Swiss Army knife for resumes and CVs build with the KISS principle in mind.", 15 | "bundleDependencies": [], 16 | "config": { 17 | "forge": { 18 | "packagerConfig": { 19 | "icon": "./app/assets/kiss-my-resume" 20 | }, 21 | "makers": [ 22 | { 23 | "name": "@electron-forge/maker-squirrel", 24 | "config": { 25 | "name": "my_new_app" 26 | } 27 | }, 28 | { 29 | "name": "@electron-forge/maker-zip", 30 | "platforms": [ 31 | "darwin" 32 | ] 33 | }, 34 | { 35 | "name": "@electron-forge/maker-deb", 36 | "config": {} 37 | }, 38 | { 39 | "name": "@electron-forge/maker-rpm", 40 | "config": {} 41 | } 42 | ], 43 | "plugins": [ 44 | [ 45 | "@electron-forge/plugin-webpack", 46 | { 47 | "mainConfig": "./webpack.main.config.js", 48 | "renderer": { 49 | "config": "./webpack.renderer.config.js", 50 | "entryPoints": [ 51 | { 52 | "html": "./app/index.html", 53 | "js": "./app/renderer/renderer.tsx", 54 | "name": "main_window", 55 | "preload": { 56 | "js": "./app/preload.ts" 57 | } 58 | } 59 | ] 60 | } 61 | } 62 | ] 63 | ] 64 | } 65 | }, 66 | "devDependencies": { 67 | "@electron-forge/cli": "6.0.0-beta.53", 68 | "@electron-forge/maker-deb": "6.0.0-beta.53", 69 | "@electron-forge/maker-rpm": "6.0.0-beta.53", 70 | "@electron-forge/maker-squirrel": "6.0.0-beta.53", 71 | "@electron-forge/maker-zip": "6.0.0-beta.53", 72 | "@electron-forge/plugin-webpack": "6.0.0-beta.53", 73 | "@intervolga/optimize-cssnano-plugin": "^1.0.6", 74 | "@marshallofsound/webpack-asset-relocator-loader": "^0.5.0", 75 | "@types/react": "^16.9.43", 76 | "@types/react-dom": "^16.9.8", 77 | "@types/webpack": "^4.41.21", 78 | "@typescript-eslint/eslint-plugin": "^2.18.0", 79 | "@typescript-eslint/parser": "^2.18.0", 80 | "copy-webpack-plugin": "^6.1.0", 81 | "css-loader": "^3.0.0", 82 | "electron": "9.3.0", 83 | "eslint": "^6.8.0", 84 | "eslint-plugin-import": "^2.20.0", 85 | "file-loader": "^6.0.0", 86 | "fork-ts-checker-webpack-plugin": "^3.1.1", 87 | "node-loader": "^0.6.0", 88 | "style-loader": "^0.23.1", 89 | "ts-loader": "^6.2.1", 90 | "typescript": "^3.7.0" 91 | }, 92 | "dependencies": { 93 | "@caporal/core": "^2.0.2", 94 | "@rjsf/core": "^2.2.2", 95 | "bootstrap3": "^3.3.5", 96 | "chalk": "^2.4.2", 97 | "electron-squirrel-startup": "^1.0.0", 98 | "express": "^4.17.1", 99 | "ez-space-css": "^1.0.0", 100 | "fresh-jrs-converter": "^1.0.0", 101 | "got": "^11.5.2", 102 | "handlebars": "^4.7.6", 103 | "html-docx-js": "^0.3.1", 104 | "is-url": "^1.2.4", 105 | "jquery": "1.9.1 - 3", 106 | "json2yaml": "^1.1.0", 107 | "jsonresume-theme-flat": "^0.3.7", 108 | "jsonresume-theme-mocha-responsive": "^1.0.0", 109 | "live-plugin-manager": "^0.15.1", 110 | "lodash.merge": ">=4.6.2", 111 | "minimist": "^1.2.5", 112 | "open": "^6.4.0", 113 | "promise.prototype.finally": "^3.1.2", 114 | "puppeteer": "^5.2.1", 115 | "react": "^16.13.1", 116 | "react-dom": "^16.13.1", 117 | "reload": "^3.1.0", 118 | "z-schema": "^4.2.3" 119 | }, 120 | "deprecated": false, 121 | "engines": { 122 | "node": ">=10.0.0" 123 | }, 124 | "keywords": [ 125 | "json", 126 | "resume", 127 | "jsonresume", 128 | "json-resume", 129 | "json-schema", 130 | "resume", 131 | "CV", 132 | "career", 133 | "CLI", 134 | "react", 135 | "form", 136 | "react.js", 137 | "electron", 138 | "cross-platform" 139 | ], 140 | "license": "MIT", 141 | "scripts": { 142 | "cli": "node lib/cli.js", 143 | "start": "electron-forge start", 144 | "package": "electron-forge package", 145 | "make": "electron-forge make", 146 | "publish": "electron-forge publish", 147 | "lint": "eslint --ext .ts ." 148 | }, 149 | "main": ".webpack/main", 150 | "bin": { 151 | "kissmyresume": "lib/cli.js" 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /app/main/ipc-event-listeners.ts: -------------------------------------------------------------------------------- 1 | import { IThemeEntry } from '../definitions'; 2 | import { BrowserView, BrowserWindow, dialog, IpcMainInvokeEvent } from 'electron'; 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | import { createMarkup, exportToMultipleFormats } from '../../lib/build'; 6 | import { fetchTheme, getThemeList, getLocalTheme } from './theme-helpers'; 7 | import { logSuccess } from '../../lib/log'; 8 | 9 | let OFFSCREEN_RENDERER: BrowserWindow; 10 | const CV_EXPORT_TIMEOUT = 5000; 11 | 12 | /** 13 | * The listener for events on the 'open-cv' channel 14 | */ 15 | export const openCvListener = async (): Promise> => { 16 | try { 17 | const openDialogReturnVal = await dialog.showOpenDialog({ 18 | title: 'Open your CV data in JSON format', 19 | filters: [ 20 | { 21 | name: 'JSON files', 22 | extensions: ['json'], 23 | } 24 | ], 25 | properties: ['openFile'] 26 | }); 27 | 28 | if (openDialogReturnVal && !openDialogReturnVal.canceled) { 29 | // built-in Promise implementations of the fs module 30 | const cvData = await fs.promises.readFile(openDialogReturnVal.filePaths[0]); 31 | return JSON.parse(cvData.toString()) 32 | } 33 | // Return null if no data loaded 34 | return null; 35 | } catch (err) { 36 | return Promise.reject(`An error occurred when opening the CV data: ${err}`); 37 | } 38 | }; 39 | 40 | /** 41 | * The listener for events on the 'save-cv' channel 42 | * @param evt {IpcMainInvokeEvent} The invoke event 43 | * @param cvData {Object} The structured CV data 44 | */ 45 | export const saveCvListener = async (evt: IpcMainInvokeEvent, cvData: Record): Promise => { 46 | try { 47 | const saveDialogReturnVal = await dialog.showSaveDialog({ 48 | title: 'Select where to save your resume in JSON format', 49 | showsTagField: false, 50 | properties: ['createDirectory', 'showOverwriteConfirmation'] 51 | }); 52 | 53 | if (saveDialogReturnVal && !saveDialogReturnVal.canceled) { 54 | const parsedFilePath = path.parse(saveDialogReturnVal.filePath); 55 | await fs.promises.writeFile(`${path.resolve(parsedFilePath.dir, parsedFilePath.name)}.json`, JSON.stringify(cvData)); 56 | } 57 | 58 | return Promise.resolve(); 59 | } catch (err) { 60 | return Promise.reject(err) 61 | } 62 | }; 63 | 64 | /** 65 | * Method processing the CD data an doing the export in different format if desired. 66 | * @param evt {IpcMainInvokeEvent} The invoke event 67 | * @param cvData {Object} The structured CV data 68 | * @param theme {IThemeEntry} The selected theme which should be used for creating HTML markup 69 | * @param selectedFormatsForExport {Object} The object with the selected formats for export 70 | * @param exportCvAfterProcessing {boolean} Whether or not should the CV be exported after processing 71 | */ 72 | export const processCvListener = async (evt: IpcMainInvokeEvent, cvData: Record, theme: IThemeEntry, 73 | selectedFormatsForExport: Record, exportCvAfterProcessing: boolean) => { 74 | try { 75 | // IDEA: run the theme render fn in sandbox - https://www.npmjs.com/package/vm2 76 | const markup = await createMarkup(cvData, await getLocalTheme(theme)); 77 | // setting of the preview content via loadURL with data-uri encoded markup is not the most robust solutions. It might 78 | // be necessary to go with file-based buffering, see https://github.com/electron/electron/issues/1146#issuecomment-591983815 79 | // alternatively https://github.com/remarkablemark/html-react-parser 80 | await BrowserView.fromId(2).webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(markup)}`); 81 | // export CV if desired 82 | if (exportCvAfterProcessing) { 83 | 84 | const saveDialogReturnVal = await dialog.showSaveDialog({ 85 | title: 'Select where to export your resume', 86 | showsTagField: false, 87 | properties: ['createDirectory', 'showOverwriteConfirmation'] 88 | }); 89 | 90 | if (saveDialogReturnVal && !saveDialogReturnVal.canceled) { 91 | const parsedFilePath = path.parse(saveDialogReturnVal.filePath); 92 | 93 | // PDF export 94 | if (selectedFormatsForExport.pdf) { 95 | const pdfData = await BrowserView.fromId(2).webContents.printToPDF({pageSize: 'A4', landscape: false}); 96 | await fs.promises.writeFile(`${path.resolve(parsedFilePath.dir, parsedFilePath.name)}.pdf`, pdfData); 97 | logSuccess('The Resume in PDF format has been saved!'); 98 | } 99 | 100 | if (selectedFormatsForExport.png) { 101 | const pageRect = await BrowserView.fromId(2).webContents.executeJavaScript( 102 | `(() => { return {x: 0, y: 0, width: document.body.offsetWidth, height: document.body.offsetHeight}})()`); 103 | 104 | OFFSCREEN_RENDERER = new BrowserWindow({ 105 | enableLargerThanScreen: true, 106 | show: false, 107 | webPreferences: { 108 | offscreen: true, 109 | nodeIntegration: false, // is default value after Electron v5 110 | contextIsolation: true, // protect against prototype pollution 111 | enableRemoteModule: false, // turn off remote 112 | } 113 | }); 114 | 115 | const timeout = setTimeout(() => { throw 'Exporting of the resume timed out!'}, CV_EXPORT_TIMEOUT); 116 | 117 | // Export the 'painted' image as screenshot 118 | OFFSCREEN_RENDERER.webContents.on('paint', async (evt, dirtyRect, image) => { 119 | await fs.promises.writeFile(`${path.resolve(parsedFilePath.dir, parsedFilePath.name)}.png`, image.toPNG()); 120 | clearTimeout(timeout); 121 | logSuccess('The Resume in PNG format has been saved!'); 122 | OFFSCREEN_RENDERER.destroy(); 123 | }); 124 | 125 | // PNG export 126 | OFFSCREEN_RENDERER.setContentSize(pageRect.width, pageRect.height); 127 | await OFFSCREEN_RENDERER.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(markup)}`); 128 | await OFFSCREEN_RENDERER.webContents.insertCSS('html, body {overflow: hidden}'); 129 | // const screenshot = await OFFSCREEN_RENDERER.webContents.capturePage(pageRect); 130 | // await fs.promises.writeFile(`${saveDialogReturnVal.filePath}.png`, screenshot.toPNG()); 131 | // logSuccess('The Resume in PNG format has been saved!'); 132 | } 133 | 134 | const remainingOutputFormats = Object.keys(selectedFormatsForExport).reduce((formats, currentFormat): Array => { 135 | if (currentFormat !== 'pdf' && currentFormat !== 'png' && selectedFormatsForExport[currentFormat]) { 136 | formats.push(currentFormat); 137 | } 138 | return formats; 139 | }, []); 140 | 141 | // HTML & DOCX export 142 | await exportToMultipleFormats(markup, parsedFilePath.name, parsedFilePath.dir, await getLocalTheme(theme), 'A4', remainingOutputFormats); 143 | } 144 | } 145 | return Promise.resolve(markup); 146 | } catch (err) { 147 | if (OFFSCREEN_RENDERER && !OFFSCREEN_RENDERER.isDestroyed()) { 148 | OFFSCREEN_RENDERER.destroy(); 149 | } 150 | return Promise.reject(`An error occurred when processing the resume: ${err}`) 151 | } 152 | }; 153 | 154 | /** 155 | * Fetches the list of themes 156 | */ 157 | export const getThemeListListener = async () => { 158 | try { 159 | return await getThemeList(); 160 | } catch (err) { 161 | return Promise.reject(err) 162 | } 163 | }; 164 | 165 | /** 166 | * Just a wrapper for the 'fetchTheme' method from theme-helpers, since we define the listeners in this module but all 167 | * theme-related stuff happens there, so we for example don't need to pass the live-plugin-manager instance reference. 168 | * @param evt {IpcMainInvokeEvent} The invoke-event bound to this listener 169 | * @param theme {IThemeEntry} The theme which should be fetched from NPM - we use the name-property as identifier. 170 | */ 171 | export const fetchThemeListener = async (evt: IpcMainInvokeEvent, theme: IThemeEntry) => { 172 | try { 173 | return await fetchTheme(theme); 174 | } catch (err) { 175 | return Promise.reject(err) 176 | } 177 | }; 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KissMyResume 2 | 3 | _[HackMyResume](https://github.com/hacksalot/HackMyResume) but [Keep it simple, (stupid)](https://en.wikipedia.org/wiki/KISS_principle)_. I really liked the original HackMyResume tool, but it lacked some important features, did not always provide the best results and I found the code base rather complicated. So I created my own version, strongly inspired by the HackMyResume project and the [resume-cli](https://github.com/jsonresume/resume-cli), but tried to __keep it simple__ by relying on off-the-shelf tools and libraries. 4 | 5 | ![Screencast](./screencast.gif) 6 | 7 | ![App-Screencast](./app-screencast.gif) 8 | 9 | The project is still WIP and in very early stage. It targets following shortcomings of the __HackMyResume__ [1] and __resume-cli__ [2] tools: 10 | * missing support for asynchronous template rendering (1,2) 11 | * PDF export relying on 3rd party tools (1) 12 | * exported PDF looking differently than HTML printed as PDF (1) 13 | * no support for local themes (2) 14 | 15 | ## Current status 16 | 17 | To provide best support for the [broad variety of 3rd party themes](https://www.npmjs.com/search?q=jsonresume-theme) the [official release (0.0.0)](https://github.com/jsonresume/resume-schema/releases/tag/0.0.0) of Json-resume schema is supported and used for validation. 18 | 19 | Since the version 1.0.0 there is a Desktop App build with [electron](www.electronjs.org) and [electron-forge](https://www.electronforge.io/) which is currently still in very early __beta__ stage. So far it allows to create resumes with a web-form generated automatically from the json-resume-scheme, allows to open and validate resumes in JSON format, render them and export in the same formats as the CLI. The GUI utilizes the CLI, so all the original functionality was preserved. 20 | 21 | The App allows to download the jsonresume-themes from NPM automatically and use them for rendering. I can not guarantee, that all 3rd party themes will work, around 30-40 were tested with satisfactory results, so far a bunch of them had to be blacklisted. In a case a theme shall not work, please open an issue on Github. 22 | 23 | ### Currently supported in the CLI 24 | 25 | * [x] CLI - implemented with the [Caporal.js](https://github.com/mattallty/Caporal.js) framework. 26 | * [x] Support for resumes in [JSON-resume](https://jsonresume.org/) format 27 | * [x] Support for [Json-resume themes](https://jsonresume.org/themes/) 28 | * [x] Export in all formats without the necessity for any 3rd party libraries/tools 29 | * [x] Export to HTML 30 | * [x] Export to PDF and PNG utilizing the [puppeteer](https://github.com/GoogleChrome/puppeteer) Headless Chrome Node API 31 | * [x] Export to DOCX with the [html-docx-js](https://github.com/evidenceprime/html-docx-js) library 32 | * [x] Export to YAML with the [json2yaml](https://git.coolaj86.com/coolaj86/json2yaml.js) utility 33 | * [x] Export to all formats at once 34 | * [x] Resume validation (JSON-Resume, FRESH) 35 | * [x] Empty resume initialization 36 | * [x] Resume HTML live preview with hot-reload 37 | 38 | 39 | ### Desktop App 40 | * [x] Resume forms (Electron App + live preview + [react-jsonschema-form](https://github.com/mozilla-services/react-jsonschema-form)) 41 | * [x] Initial app built around react and react-jsonschema-form works 42 | * [x] Created app Tested on MacOs 43 | * [x] Allows to read json-resume data to the form 44 | * [x] Further integration with the CLI 45 | * [x] Split-pane with preview 46 | * [x] Theme support with possibility to download jsonresume-themes from NPM 47 | * [ ] Possibility to delete downloaded themes 48 | * [ ] Support for local themes 49 | * [x] Export of the rendered resume in ALL formats 50 | * [x] Selecting formats for export 51 | * [ ] More mature GUI, improved styling 52 | 53 | ### To do 54 | 55 | * [ ] Spellchecking [node-spellchecker](https://github.com/atom/node-spellchecker) 56 | * [ ] Proof-Reading of the result [Proofreader](https://github.com/kdzwinel/Proofreader) 57 | * [ ] Resume conversion (JSON-Resume ⟷ FRESH) 58 | * [ ] Support for FRESH resumes through conversion 59 | * [ ] Resume editor (Electron App + live preview + [Json editor](https://github.com/josdejong/jsoneditor)) 60 | * [ ] Resume analysis 61 | * [ ] Normalizing validation error messages [(z-schema-errors)](https://github.com/dschenkelman/z-schema-errors) 62 | * [ ] Improve error handling and server life-cycle when serving the resume 63 | * [ ] ... 64 | 65 | ## Getting Started 66 | 67 | Install globally from the NPM 68 | 69 | ```bash 70 | npm install -g kiss-my-resume 71 | ``` 72 | 73 | You can also install locally and use the `npm link` command to create the _kissmyresume_ command 74 | 75 | ```bash 76 | npm install kiss-my-resume 77 | 78 | npm link 79 | ``` 80 | 81 | ## Usage 82 | 83 | ```bash 84 | kissmyresume 0.8.0 85 | 86 | USAGE 87 | 88 | kissmyresume [options] 89 | 90 | COMMANDS 91 | 92 | build Build your resume to the destination format(s). 93 | new Create a new resume in JSON Resume format. 94 | validate Validate structure and syntax of your resume. 95 | serve Show your resume in a browser with hot-reloading upon resume changes 96 | help Display help for a specific command 97 | 98 | GLOBAL OPTIONS 99 | 100 | -h, --help Display help 101 | -V, --version Display version 102 | --no-color Disable colors 103 | --quiet Quiet mode - only displays warn and error messages 104 | -v, --verbose Verbose mode - will also output debug messages 105 | ``` 106 | ### Build 107 | ```bash 108 | USAGE 109 | 110 | cli.js build 111 | 112 | ARGUMENTS 113 | 114 | The path to the source JSON resume file. required 115 | 116 | OPTIONS 117 | 118 | -f, --format Set output format (HTML|PDF|YAML|DOCX|PNG|ALL) optional default: "all" 119 | -p, --paper-size Set output size for PDF files (A4|Letter|Legal|Tabloid|Ledger|A0|A1|A2|A3|A5|A6) optional default: "A4" 120 | -o, --out Set output directory optional default: "./out" 121 | -n, --name Set output file name optional default: "resume" 122 | -t, --theme Set the theme you wish to use optional default: "jsonresume-theme-flat" 123 | ``` 124 | The default theme for the resume is the [flat-theme](https://github.com/erming/jsonresume-theme-flat) - same as resume-cli. You can use local themes or themes installed from NPM with the `-t, --theme` option flag. You can use the theme name `flat`, npm package name `jsonresume-theme-flat` or a local path `node_modules/jsonresume-theme-flat`. 125 | 126 | The theme must expose a __render__ method returning the the HTML markup in its entry-point file. The theme can expose a __renderAsync__ method returning a Promise resolving to HTML Markup. With this, the theme will be still compatible with the HackMyResume and resume-cli tools. 127 | 128 | Export to Docx is very basic and supports images as long they are encoded in Base64 and included within the HTML markup ` 154 | 155 | ARGUMENTS 156 | 157 | The path to the source JSON resume file to be validate. required 158 | ``` 159 | 160 | Does some basic validation, printing either a success message or list of errors found by the validator. 161 | ```bash 162 | --- Your resume contains errors --- 163 | 164 | # Additional properties not allowed: level in #/languages/1 165 | 166 | # Additional properties not allowed: years in #/languages/1 167 | ``` 168 | 169 | ### Serve 170 | ```bash 171 | USAGE 172 | 173 | kissmyresume serve 174 | 175 | ARGUMENTS 176 | 177 | The path to the source JSON resume file to be served. required 178 | 179 | OPTIONS 180 | 181 | -t, --theme Set the theme you wish to use optional default: "jsonresume-theme-flat" 182 | -p, --port Set the port the webserver will be listening on optional default: 3000 183 | ``` 184 | 185 | Renders the resume to HTML with the selected theme, starts web server at the selected port, opens the rendered HTML in the default browser and watches the resume source for changes. Are changes detected, the resume will re-rendered and the page will be automatically reloaded. 186 | 187 | ## License 188 | MIT. Go crazy. See LICENSE.md for details. 189 | -------------------------------------------------------------------------------- /lib/build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const puppeteer = require('puppeteer'); 6 | const YAML = require('json2yaml'); 7 | const HtmlDocx = require('html-docx-js'); 8 | 9 | const promiseFinally = require('promise.prototype.finally'); 10 | 11 | const { parseResumeFromSource } = require('./parse'); 12 | const { logSuccess, logError } = require('./log'); 13 | 14 | // Add `finally()` to `Promise.prototype` 15 | promiseFinally.shim(); 16 | 17 | // Used for PNG output 18 | const CHROME_PAGE_VIEWPORT = {width: 1280, height: 960}; 19 | 20 | // Which formats are supported 21 | const SUPPORTED_FORMATS = { 22 | 'html': true, 23 | 'pdf': true, 24 | 'yaml': true, 25 | 'docx': true, 26 | 'png': true, 27 | }; 28 | 29 | /** 30 | * Exports a HTML markup as HTML file 31 | * @param markup {string} The HTML Markup containing the rendered resume 32 | * @param name {string} Name for the output file(s) 33 | * @param outputPath {string} Path where the output file will be stored 34 | */ 35 | const exportToHtml = (markup, name, outputPath) => { 36 | fs.writeFile(`${path.resolve(outputPath, name)}.html`, markup, (err) => { 37 | if (err) throw err; 38 | logSuccess('The Resume in HTML format has been saved!'); 39 | }); 40 | }; 41 | 42 | /** 43 | * Exports a parsed resume in YAML format. 44 | * @param resumeJson {Object} The Object containing the parsed resume 45 | * @param name {string} Name for the output file 46 | * @param outputPath {string} Path where the output file will be stored 47 | */ 48 | const exportToYaml = (resumeJson, name, outputPath ) => { 49 | fs.writeFile(`${path.resolve(outputPath, name)}.yaml`, YAML.stringify(resumeJson), (err) => { 50 | if (err) throw err; 51 | logSuccess('The Resume in YAML format has been saved!'); 52 | }); 53 | }; 54 | 55 | /** 56 | * Exports a parsed resume in JSON format. Used for creating new resume and conversion 57 | * @param resumeJson {Object} The Object containing the parsed resume 58 | * @param name {string} Name for the output file 59 | * @param outputPath {string} Path where the output file will be stored 60 | */ 61 | const exportToJson = (resumeJson, name, outputPath ) => { 62 | fs.writeFile(`${path.resolve(outputPath, name)}.json`, JSON.stringify(resumeJson), (err) => { 63 | if (err) throw err; 64 | logSuccess('The Resume in JSON format has been saved!'); 65 | }); 66 | }; 67 | 68 | /** 69 | * Exports a HTML markup as PDF document or as a PNG image 70 | * @param markup {string} The HTML Markup containing the rendered resume 71 | * @param name {string} Name for the output file(s) 72 | * @param outputPath {string} Path where the output file will be stored 73 | * @param toPdf {boolean} Whether or not export to PDF 74 | * @param toPng {boolean} Whether or not export to PNG 75 | */ 76 | const exportToPdfAndPng = async (markup, name, outputPath, toPdf = true, toPng = true, paperSize = 'A4') => { 77 | // Do not proceed if no output will be generated 78 | if (!toPdf && !toPng) { return; } 79 | 80 | let browser; 81 | 82 | try { 83 | // Launch headless chrome 84 | browser = await puppeteer.launch({ 85 | headless: true, 86 | // CHROMIUM_BINARY is defined by webpacks DefinePlugin, when undefined we are not in the APP environment 87 | executablePath: CHROMIUM_BINARY ? path.resolve(__dirname, CHROMIUM_BINARY) : puppeteer.executablePath() 88 | }); 89 | const page = await browser.newPage(); 90 | // Set page viewport - important for screenshot 91 | await page.setViewport(CHROME_PAGE_VIEWPORT); 92 | // wait until networkidle0 needed when loading external styles, fonts ... 93 | await page.setContent(markup, { waitUntil: 'networkidle0' }); 94 | // export to PDF 95 | if (toPdf) { 96 | // Save as pdf 97 | await page.pdf( 98 | { 99 | format: paperSize, 100 | path: `${path.resolve(outputPath, name)}.pdf`, 101 | printBackground: true, 102 | }); 103 | logSuccess('The Resume in PDF format has been saved!'); 104 | } 105 | // export to PNG 106 | if (toPng) { 107 | // Save as png 108 | await page.screenshot( 109 | { 110 | type: 'png', 111 | fullPage: true, 112 | path: `${path.resolve(outputPath, name)}.png`, 113 | }); 114 | logSuccess('The Resume in PNG format has been saved!'); 115 | } 116 | } catch(err) { 117 | throw err; 118 | } finally { 119 | // close the browser 120 | !!browser && await browser.close(); 121 | } 122 | }; 123 | 124 | /** 125 | * Exports a HTML markup as Word document in DocX format 126 | * @param markup {string} The HTML Markup containing the rendered resume 127 | * @param name {string} Name for the output file(s) 128 | * @param outputPath {string} Path where the output file will be stored 129 | */ 130 | const exportToDocx = (markup, name, outputPath) => { 131 | /** 132 | * It is possible to define additional options. See https://www.npmjs.com/package/html-docx-js#usage-and-demo 133 | * 134 | *{ 135 | orientation: 'portrait', 136 | margins: { 137 | top: ..., 138 | right: ..., 139 | bottom: ..., 140 | left: ..., 141 | } 142 | } 143 | */ 144 | const options = {}; 145 | 146 | const document = HtmlDocx.asBlob(markup, options); 147 | 148 | fs.writeFile(`${path.resolve(outputPath, name)}.docx`, document, (err) => { 149 | if (err) throw err; 150 | logSuccess('The Resume in DOCX format has been saved!'); 151 | }); 152 | }; 153 | 154 | /** 155 | * Wrapper for efficient export to all supported formats from source 156 | * @param sourcePath {string} Source path to the resume to be parsed 157 | * @param name {string} Name for the output file(s) 158 | * @param outputPath {string} Path where the output files will be stored 159 | * @param theme {Object} The theme object with exposed render method 160 | * @param paperSize {string} The string representation of the paper size - see puppeteer options 161 | * @param outputFormats {Array} Array containing the formats which shall be used for export 162 | */ 163 | const exportToMultipleFormatsFromSource = async (sourcePath, name, outputPath, theme, paperSize, outputFormats = []) => { 164 | try { 165 | const resumeJson = parseResumeFromSource(sourcePath).resume; 166 | // Prefer async rendering 167 | const markup = theme.renderAsync ? await theme.renderAsync(resumeJson) : await theme.render(resumeJson); 168 | await exportToMultipleFormats(markup, name, outputPath, theme, paperSize, outputFormats, resumeJson) 169 | } catch(err) { 170 | throw err; 171 | } 172 | }; 173 | 174 | /** 175 | * Wrapper for efficient export to all supported formats (from markup) 176 | * @param markup {string} The markup rendered from the parsed resume 177 | * @param name {string} Name for the output file(s) 178 | * @param outputPath {string} Path where the output files will be stored 179 | * @param theme {Object} The theme object with exposed render method 180 | * @param paperSize {string} The string representation of the paper size - see puppeteer options 181 | * @param outputFormats {Array} Array containing the formats which shall be used for export 182 | * @param resumeJson {Object=} The parsed resume data - optional 183 | */ 184 | const exportToMultipleFormats = async (markup, name, outputPath, theme, paperSize, outputFormats = [], resumeJson) => { 185 | try { 186 | const formatsToExport = outputFormats.reduce((acc, currentVal) => { 187 | if (SUPPORTED_FORMATS[currentVal]) { 188 | acc[currentVal] = true; 189 | return acc; 190 | } 191 | }, {}); 192 | 193 | // Do not bother rendering when no export will be done 194 | if (Object.keys(formatsToExport).length < 1) { return } 195 | 196 | // html export 197 | if (formatsToExport.html) { 198 | exportToHtml(markup, name, outputPath) 199 | } 200 | 201 | // pdf/png export 202 | if (formatsToExport.pdf || formatsToExport.png) { 203 | await exportToPdfAndPng(markup, name, outputPath, formatsToExport.pdf, formatsToExport.png, paperSize); 204 | } 205 | 206 | // yaml export 207 | if (formatsToExport.yaml && resumeJson) { 208 | exportToYaml(resumeJson, name, outputPath) 209 | } 210 | 211 | // docs export 212 | if (formatsToExport.docx) { 213 | exportToDocx(markup, name, outputPath) 214 | } 215 | } catch(err) { 216 | logError(`Export to multiple formats failed! Reason: ${err}`); 217 | } 218 | }; 219 | 220 | /** 221 | * Wrapper for common steps for most export methods: parse resume and use its output to create the HTML markup. Calls 222 | * the createMarkup wrapper and returns its output. 223 | * @param sourcePath {string} Source path to the resume to be parsed 224 | * @param theme {Object} The theme object with exposed render method 225 | * @param logging {boolean} Whether the method should do any logging or not 226 | * @returns {Promise} Promise resolving to HTML markup as a string 227 | */ 228 | const createMarkupFromSource = async (sourcePath, theme, logging = true) => { 229 | try { 230 | const resumeJson = parseResumeFromSource(sourcePath, logging).resume; 231 | // Prefer async rendering 232 | return createMarkup(resumeJson, theme, logging) 233 | } catch(err) { 234 | throw err; 235 | } 236 | }; 237 | 238 | /** 239 | * Wrapper for rendering rhe resume: use resume data to create the HTML markup. Calls the renderAsync method or render 240 | * when no async provided. 241 | * @param resumeJson {Object} The parsed resume object 242 | * @param theme {Object} The theme object with exposed render method 243 | * @returns {Promise} Promise resolving to HTML markup as a string 244 | */ 245 | const createMarkup = async (resumeJson, theme) => { 246 | try { 247 | // Prefer async rendering 248 | return theme.renderAsync ? await theme.renderAsync(resumeJson) : await theme.render(resumeJson); 249 | } catch(err) { 250 | throw err; 251 | } 252 | }; 253 | 254 | module.exports = { 255 | exportCvFromSource: async (sourcePath, name, outputPath, theme, paperSize, formats) => { 256 | try { 257 | if (formats.length > 0) { 258 | await exportToMultipleFormatsFromSource(sourcePath, name, outputPath, theme, paperSize, formats) 259 | } else { 260 | throw 'No formats specified when exporting CV from source' 261 | } 262 | } catch (err) { 263 | if (formats.length === 1) { 264 | logError(`Export CV to ${formats[0].toUpperCase()} from source failed! Reason: ${err}`) 265 | } else { 266 | logError(`Export CV to multiple format from source failed! Reason: ${err}`) 267 | } 268 | } 269 | 270 | }, 271 | exportToMultipleFormats, 272 | createMarkupFromSource, 273 | createMarkup, 274 | exportToJson, 275 | }; 276 | -------------------------------------------------------------------------------- /lib/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | const fs = require('fs'); 8 | const path = require('path'); 9 | const { program } = require("@caporal/core"); 10 | 11 | const flatTheme = require('jsonresume-theme-flat'); 12 | 13 | const { logInfo, logSuccess, logError, setLogLevelToDebug, getErrorCount } = require('./log'); 14 | const build = require('./build'); 15 | const { parseResumeFromSource } = require('./parse'); 16 | const { validateResume } = require('./validate'); 17 | 18 | const { DEFAULT_PORT, serveResume, stopServingResume } = require('./serve'); 19 | 20 | const DEFAULT_OUTPUT_PATH = './resume out'; 21 | const DEFAULT_RESUME_PATH = './resume'; 22 | const DEFAULT_NAME = 'resume'; 23 | const DEFAULT_THEME = 'jsonresume-theme-flat'; 24 | const SUPPORTED_FORMATS = { 25 | 'html': true, 26 | 'pdf': true, 27 | 'yaml': true, 28 | 'docx': true, 29 | 'png': true, 30 | }; 31 | // Paper sizes supported by Puppeteer 32 | // https://github.com/puppeteer/puppeteer/blob/v2.0.0/docs/api.md#pagepdfoptions 33 | const SUPPORTED_PAPER_SIZES = { 34 | 'Letter': true, 35 | 'Legal': true, 36 | 'Tabloid': true, 37 | 'Ledger': true, 38 | 'A0': true, 39 | 'A1': true, 40 | 'A2': true, 41 | 'A3': true, 42 | 'A4': true, 43 | 'A5': true, 44 | 'A6': true 45 | }; 46 | 47 | 48 | /** 49 | * Validator method for the format option flag 50 | * @param opt {string} The format option flag value 51 | * @returns {string} The processed format option flag value 52 | */ 53 | const formatOptionValidator = (opt) => { 54 | 55 | const allowedFormats = Object.entries(SUPPORTED_FORMATS).map(([format, allowed]) => { 56 | if (allowed) return format; 57 | }).concat('all'); 58 | 59 | if (opt === true) { 60 | throw new Error(`You have to choose one of these formats: ${allowedFormats}`); 61 | } 62 | 63 | const option = opt.toLowerCase(); 64 | 65 | if (allowedFormats.includes(option) === false) { 66 | throw new Error(`At the moment only following formats are supported: ${allowedFormats}`); 67 | } 68 | return option; 69 | }; 70 | 71 | /** 72 | * Validator method for the paper-size option flag 73 | * @param opt {string} The paper-size option flag value 74 | * @returns {string} The processed paper-size option flag value 75 | */ 76 | const paperSizeOptionValidator = (opt) => { 77 | 78 | const allowedPaperSizes = Object.entries(SUPPORTED_PAPER_SIZES).map(([paperSize, allowed]) => { 79 | if (allowed) return paperSize; 80 | }); 81 | 82 | if (opt === true) { 83 | throw new Error(`You have to choose one of these paper sizes: ${allowedPaperSizes}`); 84 | } 85 | 86 | const option = opt.charAt(0).toUpperCase() + opt.slice(1).toLowerCase() 87 | 88 | if (allowedPaperSizes.includes(option) === false) { 89 | throw new Error(`At the moment only following paper sizes are supported: ${allowedPaperSizes}`); 90 | } 91 | return option; 92 | }; 93 | 94 | /** 95 | * Validator method for the out option flag 96 | * @param opt {string} The out option flag value 97 | * @param defaultPath {string} The default path to which no provided path will resolve 98 | * @returns {string} The processed out option flag value 99 | */ 100 | const outOptionValidator = (opt, defaultPath = DEFAULT_OUTPUT_PATH) => { 101 | 102 | const dir = path.resolve(process.cwd(), opt === true ? defaultPath : opt); 103 | 104 | try { 105 | fs.mkdirSync(dir); 106 | } catch (err) { 107 | if (err.code == 'EEXIST') { 108 | if (fs.lstatSync(dir).isDirectory()) { 109 | return dir 110 | } 111 | } else if (err.code == 'EACCES') { 112 | throw new Error(`Cannot create output directory ${dir}: permission denied!`); 113 | } 114 | else { 115 | throw new Error(`Problem with selected output directory ${dir}: ${err}!`); 116 | } 117 | } 118 | 119 | return dir 120 | }; 121 | 122 | /** 123 | * Validator method for the name option flag 124 | * @param opt {string} The name option flag value 125 | * @returns {string} The processed name option flag value 126 | */ 127 | const nameOptionValidator = (opt) => { 128 | return opt === true ? DEFAULT_NAME : opt; 129 | }; 130 | 131 | /** 132 | * Validator method for the theme option flag. Different provided values will be supported: short theme name (flat), 133 | * full theme name (jsonresume-theme-flat) or path to a local theme. 134 | * @param opt {string} The theme option flag value 135 | * @returns {Object} The selected theme 136 | */ 137 | const themeOptionValidator = (opt) => { 138 | // the default theme is flatTheme 139 | if (opt === true ) return flatTheme; 140 | 141 | let theme; 142 | // check if theme is a path 143 | try { 144 | if (fs.existsSync(opt)) { 145 | theme = require(path.resolve(opt)); 146 | } else { 147 | // no local path - could be a theme name 148 | let themeName = opt; 149 | // support partial theme names 150 | if(!themeName.match('jsonresume-theme-.*')){ 151 | themeName = `jsonresume-theme-${themeName}`; 152 | } 153 | theme = require(themeName); 154 | } 155 | if (typeof theme.render === 'function') { 156 | // might be a theme, provide 157 | return theme; 158 | } else { 159 | throw new Error(`The provided theme ${opt} does not provide the render function!`); 160 | } 161 | } 162 | catch(err) { 163 | throw err; 164 | } 165 | }; 166 | 167 | /** 168 | * Validator method for the out option flag when creating a new resume 169 | * @param opt {string} The out option flag value 170 | * @returns {string} The processed out option flag value 171 | */ 172 | const resumeOutOptionValidator = (opt) => { 173 | return outOptionValidator(opt, DEFAULT_RESUME_PATH) 174 | }; 175 | 176 | /** 177 | * Validator method for the port option flag when serving a resume with a web server 178 | * @param opt {string} The port option flag value 179 | * @returns {port} The processed out option flag value 180 | */ 181 | const serverPortOptionValidator = (opt) => { 182 | const portNr = parseInt(opt); 183 | if (!(Number.isInteger(portNr) && portNr >= 0 && portNr <= 65535)) { 184 | throw new Error(`The provided value ${opt} is not valid port number!`); 185 | } 186 | return portNr; 187 | }; 188 | 189 | 190 | // Get the version from package.json 191 | const version = require('../package.json').version; 192 | // Provide it in the CLI 193 | program.version(version); 194 | 195 | // CLI setting for the "build" command 196 | program.command('build', 'Build your resume to the destination format(s).') 197 | .argument('', 'The path to the source JSON resume file.') 198 | .option('-f, --format ', 'Set output format (HTML|PDF|YAML|DOCX|PNG|ALL)', { validator: formatOptionValidator, default: 'all' }) 199 | .option('-p, --paper-size ', 'Set output size for PDF files (A4|Letter|Legal|Tabloid|Ledger|A0|A1|A2|A3|A5|A6)', { validator: paperSizeOptionValidator, default: 'A4' }) 200 | .option('-o, --out ', 'Set output directory', { validator: outOptionValidator, default: DEFAULT_OUTPUT_PATH }) 201 | .option('-n, --name ', 'Set output file name', { validator: nameOptionValidator, default: DEFAULT_NAME }) 202 | .option('-t, --theme ', 'Set the theme you wish to use', { validator: themeOptionValidator, default: DEFAULT_THEME }) 203 | .action(( {args, options, logger }) => { 204 | 205 | 206 | logInfo(`+++ KissMyResume v${version} +++`); 207 | // set log level to debug if global verbose parameter was set 208 | if (logger.level === 'silly') { setLogLevelToDebug() } 209 | 210 | const sourcePath = path.resolve(process.cwd(), args.source ); 211 | 212 | switch (options.format) { 213 | case 'all': 214 | build.exportCvFromSource(sourcePath, options.name, options.out, options.theme, options.paperSize, ['html', 'pdf', 'yaml', 'docx']); 215 | break; 216 | case 'html': 217 | build.exportCvFromSource(sourcePath, options.name, options.out, options.theme, null,['html']); 218 | break; 219 | case 'pdf': 220 | build.exportCvFromSource(sourcePath, options.name, options.out, options.theme, options.paperSize, ['pdf']); 221 | break; 222 | case 'png': 223 | build.exportCvFromSource(sourcePath, options.name, options.out, options.theme, null,['png']); 224 | break; 225 | case 'yaml': 226 | build.exportCvFromSource(sourcePath, options.name, options.out, null,['yaml']); 227 | break; 228 | case 'docx': 229 | build.exportCvFromSource(sourcePath, options.name, options.out, null,['docx']); 230 | break; 231 | } 232 | }); 233 | 234 | // CLI setting for the "new" command 235 | program.command('new', 'Create a new resume in JSON Resume format.') 236 | .argument('', 'The name for the new resume file.') 237 | .option('-o, --out ', 'Set output directory', { validator: resumeOutOptionValidator, default: DEFAULT_RESUME_PATH }) 238 | .action(({ args, options, logger }) => { 239 | 240 | logInfo(`+++ KissMyResume v${version} +++`); 241 | // set log level to debug if global verbose parameter was set 242 | if (logger.level === 'silly') { setLogLevelToDebug() } 243 | 244 | const destinationPath = path.resolve(process.cwd(), options.out ); 245 | const newResumeName = path.basename(args.name, '.json'); 246 | 247 | logInfo(`Creating new empty resume ${path.resolve(destinationPath, `${newResumeName}.json`)}`); 248 | 249 | build.exportResumeToJson(path.resolve(__dirname ,'resume/empty-json-resume.json'), newResumeName, destinationPath); 250 | }); 251 | 252 | // CLI setting for the "validate" command 253 | program.command('validate', 'Validate structure and syntax of your resume.') 254 | .argument('', 'The path to the source JSON resume file to be validate.') 255 | .action(({ args, options, logger }) => { 256 | 257 | logInfo(`+++ KissMyResume v${version} +++`); 258 | // set log level to debug if global verbose parameter was set 259 | if (logger.level === 'silly') { setLogLevelToDebug() } 260 | 261 | try { 262 | const sourcePath = path.resolve(process.cwd(), args.source ); 263 | const {resume, type} = parseResumeFromSource(sourcePath); 264 | validateResume(resume, type); 265 | } catch(err) { 266 | logError(`Resume validation failed! Reason: ${err}`) 267 | } 268 | 269 | }); 270 | 271 | // CLI setting for the "serve" command 272 | program.command('serve', 'Show your resume in a browser with hot-reloading upon resume changes') 273 | .argument('', 'The path to the source JSON resume file to be served.') 274 | .option('-t, --theme ', 'Set the theme you wish to use', { validator: themeOptionValidator, default: DEFAULT_THEME }) 275 | .option('-p, --port ', 'Set the port the webserver will be listening on', { validator: serverPortOptionValidator, default: DEFAULT_PORT }) 276 | .action(async ({ args, options, logger }) => { 277 | 278 | logInfo(`+++ KissMyResume v${version} +++`); 279 | // set log level to debug if global verbose parameter was set 280 | if (logger.level === 'silly') { setLogLevelToDebug() } 281 | 282 | try { 283 | const sourcePath = path.resolve(process.cwd(), args.source ); 284 | await serveResume(sourcePath, options.theme, options.port); 285 | 286 | process.on('SIGINT', () => { 287 | logInfo( "Gracefully shutting down from Ctrl-C (SIGINT)" ); 288 | stopServingResume().then((message) => { 289 | logSuccess(message); 290 | process.exit(1); 291 | }) 292 | }); 293 | } catch(err) { 294 | logError(`Resume serving failed! Reason: ${err}`); 295 | process.exit(0); 296 | } 297 | 298 | }); 299 | 300 | // Run KissMyResume by parsing the input arguments 301 | program.run(process.argv.slice(2)) 302 | .then(() => { 303 | const errorCount = getErrorCount(); 304 | if (errorCount > 0) { 305 | console.error(`KissMyResume finished with ${errorCount} error${errorCount > 1 ? 's' : ''}`); 306 | 307 | } 308 | }); 309 | -------------------------------------------------------------------------------- /schemes/json-resume-schema_0.0.0.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "Resume Schema", 4 | "type": "object", 5 | "additionalProperties": false, 6 | "properties": { 7 | "basics": { 8 | "type": "object", 9 | "additionalProperties": false, 10 | "properties": { 11 | "name": { 12 | "type": "string" 13 | }, 14 | "label": { 15 | "type": "string", 16 | "description": "e.g. Web Developer" 17 | }, 18 | "picture": { 19 | "type": "string", 20 | "description": "URL (as per RFC 3986) to a picture in JPEG or PNG format" 21 | }, 22 | "email": { 23 | "type": "string", 24 | "description": "e.g. thomas@gmail.com", 25 | "format": "email" 26 | }, 27 | "phone": { 28 | "type": "string", 29 | "description": "Phone numbers are stored as strings so use any format you like, e.g. 712-117-2923" 30 | }, 31 | "website": { 32 | "type": "string", 33 | "description": "URL (as per RFC 3986) to your website, e.g. personal homepage", 34 | "format": "uri" 35 | }, 36 | "summary": { 37 | "type": "string", 38 | "description": "Write a short 2-3 sentence biography about yourself" 39 | }, 40 | "location": { 41 | "type": "object", 42 | "additionalProperties": false, 43 | "properties": { 44 | "address": { 45 | "type": "string", 46 | "description": "To add multiple address lines, use \n. For example, 1234 Glücklichkeit Straße\nHinterhaus 5. Etage li." 47 | }, 48 | "postalCode": { 49 | "type": "string" 50 | }, 51 | "city": { 52 | "type": "string" 53 | }, 54 | "countryCode": { 55 | "type": "string", 56 | "description": "code as per ISO-3166-1 ALPHA-2, e.g. US, AU, IN" 57 | }, 58 | "region": { 59 | "type": "string", 60 | "description": "The general region where you live. Can be a US state, or a province, for instance." 61 | } 62 | } 63 | }, 64 | "profiles": { 65 | "type": "array", 66 | "description": "Specify any number of social networks that you participate in", 67 | "additionalItems": false, 68 | "items": { 69 | "type": "object", 70 | "additionalProperties": false, 71 | "properties": { 72 | "network": { 73 | "type": "string", 74 | "description": "e.g. Facebook or Twitter" 75 | }, 76 | "username": { 77 | "type": "string", 78 | "description": "e.g. neutralthoughts" 79 | }, 80 | "url": { 81 | "type": "string", 82 | "description": "e.g. http://twitter.com/neutralthoughts" 83 | } 84 | } 85 | } 86 | } 87 | } 88 | }, 89 | "work": { 90 | "type": "array", 91 | "additionalItems": false, 92 | "items": { 93 | "type": "object", 94 | "additionalProperties": false, 95 | "properties": { 96 | "company": { 97 | "type": "string", 98 | "description": "e.g. Facebook" 99 | }, 100 | "position": { 101 | "type": "string", 102 | "description": "e.g. Software Engineer" 103 | }, 104 | "website": { 105 | "type": "string", 106 | "description": "e.g. http://facebook.com", 107 | "format": "uri" 108 | }, 109 | "startDate": { 110 | "type": "string", 111 | "description": "resume.json uses the ISO 8601 date standard e.g. 2014-06-29", 112 | "format": "date" 113 | }, 114 | "endDate": { 115 | "type": "string", 116 | "description": "e.g. 2012-06-29", 117 | "format": "date" 118 | }, 119 | "summary": { 120 | "type": "string", 121 | "description": "Give an overview of your responsibilities at the company" 122 | }, 123 | "highlights": { 124 | "type": "array", 125 | "description": "Specify multiple accomplishments", 126 | "additionalItems": false, 127 | "items": { 128 | "type": "string", 129 | "description": "e.g. Increased profits by 20% from 2011-2012 through viral advertising" 130 | } 131 | } 132 | } 133 | 134 | } 135 | }, 136 | "volunteer": { 137 | "type": "array", 138 | "additionalItems": false, 139 | "items": { 140 | "type": "object", 141 | "additionalProperties": false, 142 | "properties": { 143 | "organization": { 144 | "type": "string", 145 | "description": "e.g. Facebook" 146 | }, 147 | "position": { 148 | "type": "string", 149 | "description": "e.g. Software Engineer" 150 | }, 151 | "website": { 152 | "type": "string", 153 | "description": "e.g. http://facebook.com", 154 | "format": "uri" 155 | }, 156 | "startDate": { 157 | "type": "string", 158 | "description": "resume.json uses the ISO 8601 date standard e.g. 2014-06-29", 159 | "format": "date" 160 | }, 161 | "endDate": { 162 | "type": "string", 163 | "description": "e.g. 2012-06-29", 164 | "format": "date" 165 | }, 166 | "summary": { 167 | "type": "string", 168 | "description": "Give an overview of your responsibilities at the company" 169 | }, 170 | "highlights": { 171 | "type": "array", 172 | "description": "Specify multiple accomplishments", 173 | "additionalItems": false, 174 | "items": { 175 | "type": "string", 176 | "description": "e.g. Increased profits by 20% from 2011-2012 through viral advertising" 177 | } 178 | } 179 | } 180 | 181 | } 182 | }, 183 | "education": { 184 | "type": "array", 185 | "additionalItems": false, 186 | "items": { 187 | "type": "object", 188 | "additionalProperties": false, 189 | "properties": { 190 | "institution": { 191 | "type": "string", 192 | "description": "e.g. Massachusetts Institute of Technology" 193 | }, 194 | "area": { 195 | "type": "string", 196 | "description": "e.g. Arts" 197 | }, 198 | "studyType": { 199 | "type": "string", 200 | "description": "e.g. Bachelor" 201 | }, 202 | "startDate": { 203 | "type": "string", 204 | "description": "e.g. 2014-06-29", 205 | "format": "date" 206 | }, 207 | "endDate": { 208 | "type": "string", 209 | "description": "e.g. 2012-06-29", 210 | "format": "date" 211 | }, 212 | "gpa": { 213 | "type": "string", 214 | "description": "grade point average, e.g. 3.67/4.0" 215 | }, 216 | "courses": { 217 | "type": "array", 218 | "description": "List notable courses/subjects", 219 | "additionalItems": false, 220 | "items": { 221 | "type": "string", 222 | "description": "e.g. H1302 - Introduction to American history" 223 | } 224 | } 225 | } 226 | 227 | 228 | } 229 | }, 230 | "awards": { 231 | "type": "array", 232 | "description": "Specify any awards you have received throughout your professional career", 233 | "additionalItems": false, 234 | "items": { 235 | "type": "object", 236 | "additionalProperties": false, 237 | "properties": { 238 | "title": { 239 | "type": "string", 240 | "description": "e.g. One of the 100 greatest minds of the century" 241 | }, 242 | "date": { 243 | "type": "string", 244 | "description": "e.g. 1989-06-12", 245 | "format": "date" 246 | }, 247 | "awarder": { 248 | "type": "string", 249 | "description": "e.g. Time Magazine" 250 | }, 251 | "summary": { 252 | "type": "string", 253 | "description": "e.g. Received for my work with Quantum Physics" 254 | } 255 | } 256 | } 257 | }, 258 | "publications": { 259 | "type": "array", 260 | "description": "Specify your publications through your career", 261 | "additionalItems": false, 262 | "items": { 263 | "type": "object", 264 | "additionalProperties": false, 265 | "properties": { 266 | "name": { 267 | "type": "string", 268 | "description": "e.g. The World Wide Web" 269 | }, 270 | "publisher": { 271 | "type": "string", 272 | "description": "e.g. IEEE, Computer Magazine" 273 | }, 274 | "releaseDate": { 275 | "type": "string", 276 | "description": "e.g. 1990-08-01" 277 | }, 278 | "website": { 279 | "type": "string", 280 | "description": "e.g. http://www.computer.org/csdl/mags/co/1996/10/rx069-abs.html" 281 | }, 282 | "summary": { 283 | "type": "string", 284 | "description": "Short summary of publication. e.g. Discussion of the World Wide Web, HTTP, HTML." 285 | } 286 | } 287 | } 288 | }, 289 | "skills": { 290 | "type": "array", 291 | "description": "List out your professional skill-set", 292 | "additionalItems": false, 293 | "items": { 294 | "type": "object", 295 | "additionalProperties": false, 296 | "properties": { 297 | "name": { 298 | "type": "string", 299 | "description": "e.g. Web Development" 300 | }, 301 | "level": { 302 | "type": "string", 303 | "description": "e.g. Master" 304 | }, 305 | "keywords": { 306 | "type": "array", 307 | "description": "List some keywords pertaining to this skill", 308 | "additionalItems": false, 309 | "items": { 310 | "type": "string", 311 | "description": "e.g. HTML" 312 | } 313 | } 314 | } 315 | } 316 | }, 317 | "languages": { 318 | "type": "array", 319 | "description": "List any other languages you speak", 320 | "additionalItems": false, 321 | "items": { 322 | "type": "object", 323 | "additionalProperties": false, 324 | "properties": { 325 | "language": { 326 | "type": "string", 327 | "description": "e.g. English, Spanish" 328 | }, 329 | "fluency": { 330 | "type": "string", 331 | "description": "e.g. Fluent, Beginner" 332 | } 333 | } 334 | } 335 | }, 336 | "interests": { 337 | "type": "array", 338 | "additionalItems": false, 339 | "items": { 340 | "type": "object", 341 | "additionalProperties": false, 342 | "properties": { 343 | "name": { 344 | "type": "string", 345 | "description": "e.g. Philosophy" 346 | }, 347 | "keywords": { 348 | "type": "array", 349 | "additionalItems": false, 350 | "items": { 351 | "type": "string", 352 | "description": "e.g. Friedrich Nietzsche" 353 | } 354 | } 355 | } 356 | 357 | } 358 | }, 359 | "references": { 360 | "type": "array", 361 | "description": "List references you have received", 362 | "additionalItems": false, 363 | "items": { 364 | "type": "object", 365 | "additionalProperties": false, 366 | "properties": { 367 | "name": { 368 | "type": "string", 369 | "description": "e.g. Timothy Cook" 370 | }, 371 | "reference": { 372 | "type": "string", 373 | "description": "e.g. Joe blogs was a great employee, who turned up to work at least once a week. He exceeded my expectations when it came to doing nothing." 374 | } 375 | } 376 | 377 | } 378 | } 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /app/renderer/components/App/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, Fragment, ChangeEvent } from 'react'; 2 | import Form, {IChangeEvent, ISubmitEvent} from '@rjsf/core'; 3 | import metaSchemaDraft04 from 'ajv/lib/refs/json-schema-draft-04.json' 4 | import JSON_RESUME_SCHEMA from '../../../../schemes/json-resume-schema_0.0.0.json' 5 | import { VALID_INVOKE_CHANNELS, INotification, IThemeEntry } from '../../../definitions' 6 | import styles from './App.css' 7 | import { useThemeList } from '../../hooks/useThemeList'; 8 | 9 | // read https://github.com/async-library/react-async 10 | export default function App() 11 | { 12 | /** 13 | * State - Hooks 14 | */ 15 | const [schema , setSchema] = useState(JSON_RESUME_SCHEMA as Record); 16 | const [cvData, setCvData] = useState({}); 17 | const [notifications, setNotifications] = useState>([]); 18 | const [themeListFetchingError, themeList, setThemeList] = useThemeList(); 19 | // Add the error when theme-list fetching failed to the notifications 20 | if (themeListFetchingError) { 21 | setNotifications([...notifications, themeListFetchingError]) 22 | } 23 | const [selectedFormatsForExport, setSelectedFormatsForExport] = useState({ 24 | pdf: true, 25 | png: false, 26 | html: false, 27 | docx: false, 28 | } as Record); 29 | const [fetchingThemeInProgress, setFetchingThemeInProgress] = useState(false); 30 | const [processingThemeInProgress, setProcessingThemeInProgress] = useState(false); 31 | const [exportCvAfterProcessing, setExportCvAfterProcessing] = useState(false); 32 | const [saveCvDataInProgress, setSaveCvDataInProgress] = useState(false); 33 | // The ref to the Form component 34 | const cvForm = useRef>(null); 35 | const themeSelector = useRef(null); 36 | 37 | // Logging helper 38 | const log = (type: any) => console.log.bind(console, type); 39 | 40 | /** 41 | * Handler for loading CV data. Uses the defined API invoke call on the open-cv chanel. Returns JSON data which is 42 | * then manually validated against the current schema of the Form. 43 | */ 44 | const handleOpenCvButtonClick = () => { 45 | window.api.invoke(VALID_INVOKE_CHANNELS['open-cv']).then((result: null | Record) => { 46 | if (result) { 47 | const { errorSchema, errors } = cvForm.current.validate(result, schema, [metaSchemaDraft04]); 48 | if (errors && errors.length) { 49 | setNotifications([...notifications, {type: 'warning', text: `${errors.length} validations errors found in the loaded data.`}]) 50 | } 51 | setCvData(result); 52 | } 53 | }).catch((err: PromiseRejectionEvent) => { 54 | // display a warning ...TBD 55 | setNotifications([...notifications, {type: 'danger', text: `Opening of CV data failed: ${err}`}]) 56 | }) 57 | }; 58 | 59 | /** 60 | * Form-data-change handler making react-jsonschema-form controlled component. 61 | * @param changeEvent {IChangeEvent} The rjsf-form change event 62 | */ 63 | const handleFormDataChange = (changeEvent: IChangeEvent) => { 64 | setCvData(changeEvent.formData); 65 | }; 66 | 67 | /** 68 | * Click-handler for the Process-cv-button which triggers the form-submit function programmatically. 69 | */ 70 | const handleProcessCvButtonClick = () => { 71 | cvForm.current.submit(); 72 | }; 73 | 74 | /** 75 | * Checkbox change-handler updating the state of the selected output formats 76 | * @param evt {HTMLInputElement} The change event of the checkbox 77 | */ 78 | const handleFormatsForExportChange = (evt: ChangeEvent) => { 79 | // ignore the setter in case of undefined or other monkey business 80 | if (typeof selectedFormatsForExport[evt.target.name] === "boolean") { 81 | const newSelectedFormatsForExportState = { ...selectedFormatsForExport, [evt.target.name]: evt.target.checked }; 82 | // Do not allow unselecting all formats 83 | if (Object.keys(newSelectedFormatsForExportState).every((k) => !newSelectedFormatsForExportState[k])) { 84 | setNotifications([...notifications, {type: 'warning', text: 'At least one format must be selected for export!'}]) 85 | return; 86 | } 87 | setSelectedFormatsForExport({...selectedFormatsForExport, [evt.target.name]: evt.target.checked}) 88 | } 89 | }; 90 | 91 | /** 92 | * Click-handler for the Export-cv-button which triggers the CV export. 93 | */ 94 | const handleExportCvButtonClick = () => { 95 | // set the export-after-processing flag to true 96 | setExportCvAfterProcessing(true); 97 | cvForm.current.submit(); 98 | }; 99 | 100 | /** 101 | * Click-handler for the Save-cv-data-button which triggers the cv data save invocation. 102 | */ 103 | const handleSaveCvDataClick = () => { 104 | setSaveCvDataInProgress(true); 105 | window.api.invoke(VALID_INVOKE_CHANNELS['save-cv'], cvData).then(() => { 106 | // TODO: notification 107 | }) .catch((err: PromiseRejectionEvent) => { 108 | // display a warning ...TBD 109 | setNotifications([...notifications, {type: 'danger', text: `Saving of CV data failed: ${err}`}]) 110 | }).finally(() => { 111 | // set the state of fetching-state-in-progress to false 112 | setSaveCvDataInProgress(false); 113 | }); 114 | }; 115 | 116 | /** 117 | * Theme-list-change handler 118 | */ 119 | const handleSelectThemeChange = (evt: ChangeEvent) => { 120 | if (fetchingThemeInProgress) { 121 | return 122 | } 123 | const selectedThemeIndex = parseInt(evt.target.value); 124 | const selectedTheme = themeList[selectedThemeIndex]; 125 | // download the theme if not present yet 126 | if (!selectedTheme.present) { 127 | // set the state of fetching-state-in-progress to true 128 | setFetchingThemeInProgress(true); 129 | window.api.invoke(VALID_INVOKE_CHANNELS['fetch-theme'], selectedTheme) 130 | .then(() => { 131 | themeList[selectedThemeIndex]['present'] = true; 132 | // update the theme list 133 | setThemeList([...themeList]); 134 | }).catch((err: PromiseRejectionEvent) => { 135 | // display a warning notification 136 | setNotifications([...notifications, {type: 'danger', text: `Fetching of theme ${selectedTheme.name} failed: ${err}`}]) 137 | }).finally(() => { 138 | // set the state of fetching-state-in-progress to false 139 | setFetchingThemeInProgress(false); 140 | }); 141 | } 142 | }; 143 | 144 | /** 145 | * The submit-event handler. 146 | */ 147 | const handleFormSubmit = (submitEvent: ISubmitEvent) => { 148 | const selectedTheme = themeList[parseInt(themeSelector.current.value)]; 149 | // set the state of processing-state-in-progress to true 150 | setProcessingThemeInProgress(true); 151 | window.api.invoke(VALID_INVOKE_CHANNELS['process-cv'], submitEvent.formData, selectedTheme, selectedFormatsForExport, exportCvAfterProcessing ) 152 | .then((markup: string) => { 153 | // TODO: success notification 154 | }).catch((err: PromiseRejectionEvent) => { 155 | // display a warning notification 156 | setNotifications([...notifications, {type: 'danger', text: `Processing of CV data failed: ${err}`}]) 157 | }).finally(() => { 158 | // set the state of fetching-state-in-progress to false 159 | setProcessingThemeInProgress(false); 160 | // set the export-after-processing flag to false 161 | setExportCvAfterProcessing(false); 162 | }); 163 | }; 164 | 165 | return
166 |
167 |
168 | 169 |
170 | { 171 | notifications.map((notification: INotification, index: number) => 172 |
{notification.text}
173 | ) 174 | } 175 |
176 |
177 |
178 |
179 | 182 | 187 | 192 |
193 | 198 |
    199 |
  • 200 |
    201 | 206 |
    207 |
  • 208 |
  • 209 |
    210 | 215 |
    216 |
  • 217 |
    218 | 223 |
    224 |
  • 225 |
    226 | 231 |
    232 |
  • 233 |
234 |
235 | 240 |
241 | 254 |
255 |
256 |
263 | {/*workaround to hide the submit button, see https://github.com/rjsf-team/react-jsonschema-form/issues/705*/} 264 | 265 | 266 |
267 | } 268 | -------------------------------------------------------------------------------- /schemes/fresh-resume-schema_1.0.0-beta.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "FRESH Resume Schema", 4 | "type": "object", 5 | "additionalProperties": true, 6 | "required": ["name", "meta"], 7 | "properties": { 8 | 9 | 10 | 11 | "name": { 12 | "type": "string", 13 | "description": "The candidate's name as it should appear on the resume." 14 | }, 15 | 16 | 17 | 18 | "meta": { 19 | "type": "object", 20 | "additionalProperties": true, 21 | "description": "The 'meta' section contains metadata information for the resume, including the resume version, schema, and any other fields required by tools.", 22 | "required": ["format"], 23 | "properties": { 24 | "format": { 25 | "type": "string", 26 | "description": "The canonical resume format and version. Should be 'FRESH@0.1.0'." 27 | }, 28 | "version": { 29 | "type": "string", 30 | "description": "The semantic version number for this resume." 31 | } 32 | } 33 | }, 34 | 35 | "info": { 36 | "type": "object", 37 | "additionalProperties": true, 38 | "description": "The 'info' section contains basic summary information for the candidate, including an optional label or job title, candidate photo, summary, and quote.", 39 | "properties": { 40 | "label": { 41 | "type": "string", 42 | "description": "A label for this resume, such as 'Full-Stack Developer'." 43 | }, 44 | "class": { 45 | "type": "string", 46 | "description": "Profession type or 'character class'." 47 | }, 48 | "image": { 49 | "type": "string", 50 | "description": "URL or path to your picture in JPEG, PNG, GIF, or BMP format." 51 | }, 52 | "brief": { 53 | "type": "string", 54 | "description": "A short description or summary of yourself as a candidate." 55 | }, 56 | "quote": { 57 | "type": "string", 58 | "description": "Candidate quote or byline." 59 | } 60 | } 61 | }, 62 | 63 | 64 | 65 | "disposition": { 66 | "type": "object", 67 | "additionalProperties": true, 68 | "description": "The disposition section describes the candidate's overall attitude towards new employment opportunities including: travel, relocation, schedule, desired type of work, and the like.", 69 | "properties": { 70 | 71 | "travel": { 72 | "type": "integer", 73 | "description": "Percentage of time willing to travel (0 to 100)." 74 | }, 75 | 76 | "authorization": { 77 | "type": "string", 78 | "description": "Work authorization: citizen, work visa, etc." 79 | }, 80 | 81 | "commitment": { 82 | "type": "array", 83 | "additionalItems": false, 84 | "description": "Types of work commitment desired: contract, perm, seasonal, etc.", 85 | "items": { 86 | "type": "string", 87 | "description": "One of: contract, permanent, part-time, seasonal, full-time." 88 | } 89 | }, 90 | 91 | "remote": { 92 | "type": "boolean", 93 | "description": "Open to remote employment opportunities." 94 | }, 95 | 96 | "relocation": { 97 | "type": "object", 98 | "additionalProperties": true, 99 | "properties": { 100 | 101 | "willing": { 102 | "type": ["string","boolean"], 103 | "description": "Willingness to relocate." 104 | }, 105 | 106 | "destinations": { 107 | "type": "array", 108 | "description": "Preferred destinations for relocation", 109 | "additionalItems": false, 110 | "items": { 111 | "type": "string", 112 | "description": "City or area of relocation." 113 | } 114 | } 115 | } 116 | } 117 | } 118 | }, 119 | 120 | 121 | 122 | "contact": { 123 | "type": "object", 124 | "additionalProperties": true, 125 | "description": "The 'contact' section contains the candidate's contact information, including phone numbers, emails, websites, IMs, and custom contact types.", 126 | "properties": { 127 | 128 | "email": { 129 | "type": "string", 130 | "description": "Primary contact email.", 131 | "format": "email" 132 | }, 133 | 134 | "phone": { 135 | "type": "string", 136 | "description": "Primary phone number." 137 | }, 138 | 139 | "website": { 140 | "type": "string", 141 | "description": "Website, blog, or home page.", 142 | "format": "uri" 143 | }, 144 | 145 | "other": { 146 | "type": "array", 147 | "additionalItems": false, 148 | "items": { 149 | "type": "object", 150 | "additionalProperties": true, 151 | "properties": { 152 | 153 | "label": { 154 | "type": "string", 155 | "description": "A label for this contact information." 156 | }, 157 | 158 | "category": { 159 | "type": "string", 160 | "description": "Type of contact information: email, phone, url, postal, or IM." 161 | }, 162 | 163 | "value": { 164 | "type": "string", 165 | "description": "Phone number, email address, website, etc." 166 | } 167 | } 168 | } 169 | } 170 | } 171 | }, 172 | 173 | 174 | 175 | "location": { 176 | "type": "object", 177 | "description": "The 'location' section, when present, contains the candidate's location and address info.", 178 | "additionalProperties": true, 179 | "properties": { 180 | 181 | "address": { 182 | "type": "string", 183 | "description": "Your full postal address." 184 | }, 185 | 186 | "code": { 187 | "type": "string", 188 | "description": "Postal or other official routing code." 189 | }, 190 | 191 | "city": { 192 | "type": "string", 193 | "description": "Your home city." 194 | }, 195 | 196 | "country": { 197 | "type": "string", 198 | "description": "Two-digit country code (US, AU, UK, IN, etc.)." 199 | }, 200 | 201 | "region": { 202 | "type": "string", 203 | "description": "Your state, region, or province." 204 | } 205 | } 206 | }, 207 | 208 | 209 | 210 | "employment": { 211 | "type": "object", 212 | "description": "The 'employment' section describes the candidate's formal employment history.", 213 | "additionalProperties": true, 214 | "properties": { 215 | 216 | "summary": { 217 | "type": "string", 218 | "description:": "Summary of overall employment." 219 | }, 220 | 221 | "history": { 222 | "type": "array", 223 | "additionalItems": false, 224 | "items": { 225 | "type": "object", 226 | "additionalProperties": true, 227 | "required": ["employer"], 228 | "properties": { 229 | 230 | "employer": { 231 | "type": "string", 232 | "description": "Employer name." 233 | }, 234 | 235 | "position": { 236 | "type": "string", 237 | "description": "Your position or formal job title." 238 | }, 239 | 240 | "url": { 241 | "type": "string", 242 | "description": "Employer website.", 243 | "format": "uri" 244 | }, 245 | 246 | "start": { 247 | "type": "string", 248 | "description": "Date you began work, in YYYY, YYYY-MM, or YYYY-MM-DD format.", 249 | "format": "date" 250 | }, 251 | 252 | "end": { 253 | "type": "string", 254 | "description": "Date you finished work, in YYYY, YYYY-MM, or YYYY-MM-DD format.", 255 | "format": "date" 256 | }, 257 | 258 | "summary": { 259 | "type": "string", 260 | "description": "A summary of your achievements and responsibilities under this employer." 261 | }, 262 | 263 | "highlights": { 264 | "type": "array", 265 | "description": "Noteworthy achievements and/or highlights.", 266 | "additionalItems": false, 267 | "items": { 268 | "type": "string", 269 | "description": "For ex, 'Led 5-person development team, increasing profits by 50% year-over-year'." 270 | } 271 | }, 272 | 273 | "location": { 274 | "type": "string", 275 | "description": "Freeform location of the job or position, e.g., 'San Francisco, CA' or 'Tokyo'." 276 | }, 277 | 278 | "keywords": { 279 | "type": "array", 280 | "description": "Keywords associated with this position.", 281 | "additionalItems": false, 282 | "items": { 283 | "type": "string", 284 | "description": "For example, C++, HTML, HIPAA, etc." 285 | } 286 | } 287 | } 288 | } 289 | } 290 | } 291 | }, 292 | 293 | 294 | 295 | "projects": { 296 | "type": "array", 297 | "additionalItems": false, 298 | "description": "The 'projects' section describes the candidate's project history -- not the jobs the candidate has worked but the specific projects and enterprises the candidate has created or been involved in, whether paid or unpaid.", 299 | "items": { 300 | "type": "object", 301 | "additionalProperties": true, 302 | "required": ["title"], 303 | "properties": { 304 | 305 | "title": { 306 | "type": "string", 307 | "description": "Project name or code-name." 308 | }, 309 | 310 | "category": { 311 | "type": "string", 312 | "description": "Project type: open-source, private, side project, etc." 313 | }, 314 | 315 | "description": { 316 | "type": "string", 317 | "description": "Project description or summary." 318 | }, 319 | 320 | "summary": { 321 | "type": "string", 322 | "description": "A summary of your achievements and responsibilities on the project." 323 | }, 324 | 325 | "role": { 326 | "type": "string", 327 | "description": "Your role on the project: Contributor, Creator, etc." 328 | }, 329 | 330 | "url": { 331 | "type": "string", 332 | "description": "Project URL.", 333 | "format": "uri" 334 | }, 335 | 336 | "media": { 337 | "type": "array", 338 | "additionalItems": false, 339 | "description": "Media associated with this project.", 340 | "items": { 341 | "type": "object", 342 | "additionalProperties": true, 343 | "required": ["category"], 344 | "properties": { 345 | "category": { 346 | "type": "string", 347 | "description": "Media category: image, thumbnail, screenshot, MP3, download, etc." 348 | }, 349 | "name": { 350 | "type": "string", 351 | "description": "Friendly media name." 352 | }, 353 | "url": { 354 | "type": "string", 355 | "description": "Media link, path, or location." 356 | } 357 | } 358 | } 359 | }, 360 | 361 | "repo": { 362 | "type": "string", 363 | "description": "Repo URL.", 364 | "format": "uri" 365 | }, 366 | 367 | "start": { 368 | "type": "string", 369 | "description": "Date your involvement with project began, in YYYY, YYYY-MM, or YYYY-MM-DD format.", 370 | "format": "date" 371 | }, 372 | 373 | "end": { 374 | "type": "string", 375 | "description": "Date your involvement with project ended, in YYYY, YYYY-MM, or YYYY-MM-DD format.", 376 | "format": "date" 377 | }, 378 | 379 | "highlights": { 380 | "type": "array", 381 | "description": "Noteworthy project-related achievements and/or highlights.", 382 | "additionalItems": false, 383 | "items": { 384 | "type": "string", 385 | "description": "For ex, 'Led 5-person development team, increasing profits by 50% year-over-year'." 386 | } 387 | }, 388 | 389 | "location": { 390 | "type": "string", 391 | "description": "Freeform location of the job or position, e.g., 'San Francisco, CA' or 'Tokyo'." 392 | }, 393 | 394 | "keywords": { 395 | "type": "array", 396 | "description": "Keywords associated with this project.", 397 | "additionalItems": false, 398 | "items": { 399 | "type": "string", 400 | "description": "For example, C++, HTML, HIPAA, etc." 401 | } 402 | } 403 | } 404 | } 405 | }, 406 | 407 | 408 | 409 | "skills": { 410 | "type": "object", 411 | "description": "A description of the candidate's formal skills and capabilities.", 412 | "additionalProperties": true, 413 | "properties": { 414 | 415 | "sets": { 416 | "type": "array", 417 | "additionalItems": false, 418 | "optional": true, 419 | "items": { 420 | "type": "object", 421 | "additionalProperties": true, 422 | "required": ["name", "skills"], 423 | "properties": { 424 | 425 | "name": { 426 | "type": "string", 427 | "description": "Name of the skillset: 'Programming' or 'Project Management' etc." 428 | }, 429 | 430 | "level": { 431 | "type": "string", 432 | "description": "Level of mastery of the skill." 433 | }, 434 | 435 | "skills": { 436 | "type": "array", 437 | "additionalItems": false, 438 | "items": { 439 | "type": "string", 440 | "description": "Title or ID of a skill from the skills list." 441 | } 442 | } 443 | } 444 | } 445 | }, 446 | 447 | "list": { 448 | "type": "array", 449 | "additionalItems": false, 450 | 451 | "items": { 452 | "type": "object", 453 | "additionalProperties": true, 454 | "required": ["name"], 455 | "properties": { 456 | 457 | "name": { 458 | "type": "string", 459 | "description": "The name or title of the skill." 460 | }, 461 | 462 | "level": { 463 | "type": "string", 464 | "description": "A freeform description of your level of mastery with the skill." 465 | }, 466 | 467 | "summary": { 468 | "type": "string", 469 | "description": "A short summary of your experience with this skill." 470 | }, 471 | 472 | "years": { 473 | "type": ["string", "number"], 474 | "description": "Number of years you've used the skill." 475 | } 476 | } 477 | } 478 | } 479 | 480 | } 481 | }, 482 | 483 | 484 | 485 | "service": { 486 | "type": "object", 487 | "description": "The 'service' section describes the candidate's overall service history in the true sense of the word 'service': volunteer work, military participation, civilian core, rescue and emergency services, and the like.", 488 | "additionalProperties": true, 489 | "properties": { 490 | 491 | "summary": { 492 | "type": "string", 493 | "description": "Summary of overall service/volunteer experience." 494 | }, 495 | 496 | "history": { 497 | "type": "array", 498 | "additionalItems": false, 499 | "items": { 500 | "type": "object", 501 | "additionalProperties": true, 502 | "required": ["organization"], 503 | "properties": { 504 | 505 | "category": { 506 | "type": "string", 507 | "description": "The type of service work, such as volunteer or military." 508 | }, 509 | 510 | "organization": { 511 | "type": "string", 512 | "description": "The service organization, such as Red Cross or National Guard." 513 | }, 514 | 515 | "position": { 516 | "type": "string", 517 | "description": "Your position or formal service role." 518 | }, 519 | 520 | "url": { 521 | "type": "string", 522 | "description": "Organization website.", 523 | "format": "uri" 524 | }, 525 | 526 | "start": { 527 | "type": "string", 528 | "description": "Date you joined the organization, in YYYY, YYYY-MM, or YYYY-MM-DD format.", 529 | "format": "date" 530 | }, 531 | 532 | "end": { 533 | "type": "string", 534 | "description": "Date you left the organization, in YYYY, YYYY-MM, or YYYY-MM-DD format.", 535 | "format": "date" 536 | }, 537 | 538 | "summary": { 539 | "type": "string", 540 | "description": "A summary of your achievements and responsibilities at this organization." 541 | }, 542 | 543 | "highlights": { 544 | "type": "array", 545 | "description": "Noteworthy achievements and/or highlights.", 546 | "additionalItems": false, 547 | "items": { 548 | "type": "string", 549 | "description": "For ex, 'Served on board of directors of national non-profit organization with 20,000 members.'." 550 | } 551 | }, 552 | 553 | "keywords": { 554 | "type": "array", 555 | "description": "Keywords associated with this service.", 556 | "additionalItems": false, 557 | "items": { 558 | "type": "string", 559 | "description": "For example, C++, HTML, HIPAA, etc." 560 | } 561 | }, 562 | 563 | "location": { 564 | "type": "string", 565 | "description": "Freeform location of the position, e.g., 'San Francisco, CA' or 'Tokyo'." 566 | } 567 | } 568 | } 569 | } 570 | } 571 | }, 572 | 573 | 574 | 575 | "education": { 576 | "type": "object", 577 | "additionalProperties": true, 578 | "description": "The 'employment' section describes the candidate's formal education, including college and university, continuing education, and standalone programs and courses.", 579 | "required": ["level"], 580 | "properties": { 581 | 582 | "summary": { 583 | "type": "string", 584 | "description:": "Summary of overall education." 585 | }, 586 | 587 | "level": { 588 | "type": "string", 589 | "description": "Highest level of education obtained (none, diploma, some-college, degree)." 590 | }, 591 | 592 | "degree": { 593 | "type": "string", 594 | "description": "Your degree, if any (BSCS, BA, etc.)." 595 | }, 596 | 597 | "history": { 598 | "type": "array", 599 | "additionalItems": false, 600 | "items": { 601 | "type": "object", 602 | "additionalProperties": true, 603 | "required": ["institution"], 604 | "properties": { 605 | 606 | "title": { 607 | "type": "string", 608 | "description": "A freeform title for this education stint. Typically, this should be the short name of your degree, certification, or training." 609 | }, 610 | 611 | "institution": { 612 | "type": "string", 613 | "description": "College or school name." 614 | }, 615 | 616 | "area": { 617 | "type": "string", 618 | "description": "e.g. Arts" 619 | }, 620 | 621 | "studyType": { 622 | "type": "string", 623 | "description": "e.g. Bachelor" 624 | }, 625 | 626 | "start": { 627 | "type": "string", 628 | "description": "Date this schooling began, in YYYY, YYYY-MM, or YYYY-MM-DD format.", 629 | "format": "date" 630 | }, 631 | 632 | "end": { 633 | "type": "string", 634 | "description": "Date this schooling ended, in YYYY, YYYY-MM, or YYYY-MM-DD format.", 635 | "format": "date" 636 | }, 637 | 638 | "grade": { 639 | "type": "string", 640 | "description": "Grade or GPA." 641 | }, 642 | 643 | "curriculum": { 644 | "type": "array", 645 | "description": "Notable courses, subjects, and educational experiences.", 646 | "additionalItems": false, 647 | "items": { 648 | "type": "string", 649 | "description": "The course name and number or other identifying info." 650 | } 651 | }, 652 | 653 | "url": { 654 | "type": "string", 655 | "description": "Website or URL of the institution or school.", 656 | "format": "uri" 657 | }, 658 | 659 | "summary": { 660 | "type": "string", 661 | "description": "A short summary of this education experience." 662 | }, 663 | 664 | "keywords": { 665 | "type": "array", 666 | "description": "Keywords associated with this education stint.", 667 | "additionalItems": false, 668 | "items": { 669 | "type": "string", 670 | "description": "For example, C++, HTML, HIPAA, etc." 671 | } 672 | }, 673 | 674 | "highlights": { 675 | "type": "array", 676 | "description": "Noteworthy achievements and/or highlights.", 677 | "additionalItems": false, 678 | "items": { 679 | "type": "string", 680 | "description": "For ex, 'Graduated *summa cum laude*'." 681 | } 682 | }, 683 | 684 | "location": { 685 | "type": "string", 686 | "description": "Freeform location of the education, e.g., 'San Francisco, CA' or 'Tokyo'." 687 | } 688 | } 689 | } 690 | } 691 | } 692 | }, 693 | 694 | 695 | 696 | "social": { 697 | "type": "array", 698 | "description": "The 'social' section describes the candidate's participation in internet and social networking services and communities including GitHub, FaceBook, and the like.", 699 | "additionalItems": false, 700 | "items": { 701 | "type": "object", 702 | "additionalProperties": true, 703 | "required": ["network", "user", "url"], 704 | "properties": { 705 | 706 | "network": { 707 | "type": "string", 708 | "description": "The name of the social network, such as Facebook or GitHub." 709 | }, 710 | 711 | "user": { 712 | "type": "string", 713 | "description": "Your username or handle on the social network." 714 | }, 715 | 716 | "url": { 717 | "type": "string", 718 | "description": "URL of your profile page on this network.", 719 | "format": "uri" 720 | }, 721 | 722 | "label": { 723 | "type": "string", 724 | "description": "A friendly label." 725 | } 726 | } 727 | } 728 | }, 729 | 730 | 731 | 732 | "recognition": { 733 | "type": "array", 734 | "description": "The 'recognition' section describes the candidate's public or professional plaudits, kudos, awards, and other forms of positive external reinforcement.", 735 | "additionalItems": false, 736 | "items": { 737 | "type": "object", 738 | "additionalProperties": true, 739 | "required": ["title"], 740 | "properties": { 741 | 742 | "category": { 743 | "type": "string", 744 | "description": "Type of recognition: award, honor, prize, etc." 745 | }, 746 | 747 | "title": { 748 | "type": "string", 749 | "description": "Title of the award or recognition." 750 | }, 751 | 752 | "date": { 753 | "type": "string", 754 | "description": "Date awarded, in YYYY, YYYY-MM, or YYYY-MM-DD format.", 755 | "format": "date" 756 | }, 757 | 758 | "from": { 759 | "type": "string", 760 | "description": "Name of the awarding company, insitution, or individual." 761 | }, 762 | 763 | "summary": { 764 | "type": "string", 765 | "description": "A brief description of the award and why you received it." 766 | }, 767 | 768 | "url": { 769 | "type": "string", 770 | "description": "A webpage or other associated URL.", 771 | "format": "uri" 772 | } 773 | } 774 | } 775 | }, 776 | 777 | 778 | 779 | "writing": { 780 | "type": "array", 781 | "description": "The 'writing' section describes the candidate's writing and publication history, from blogs and essays to novels and dissertations.", 782 | "additionalItems": false, 783 | "items": { 784 | "type": "object", 785 | "additionalProperties": true, 786 | "required": ["writing"], 787 | "properties": { 788 | 789 | "title": { 790 | "type": "string", 791 | "description": "Title of the article, essay, or book." 792 | }, 793 | 794 | "category": { 795 | "type": "string", 796 | "description": "One of 'book', 'article', 'essay', 'blog post', or 'series'." 797 | }, 798 | 799 | "publisher": { 800 | "type": ["object","string"], 801 | "description": "Publisher of the article, essay, or book.", 802 | "optional": true, 803 | "additionalProperties": true, 804 | "properties": { 805 | 806 | "name": { 807 | "type": "string", 808 | "description": "Publisher of the article, essay, or book." 809 | }, 810 | 811 | "url": { 812 | "type": "string", 813 | "description": "Publisher website or URL." 814 | } 815 | } 816 | }, 817 | 818 | "date": { 819 | "type": "string", 820 | "format": "date", 821 | "description": "Publication date in YYYY, YYYY-MM, or YYYY-MM-DD format." 822 | }, 823 | 824 | "url": { 825 | "type": "string", 826 | "description": "Website or URL of this writing or publication." 827 | }, 828 | 829 | "summary": { 830 | "type": "string", 831 | "description": "A brief summary of the publication." 832 | } 833 | } 834 | } 835 | }, 836 | 837 | 838 | 839 | "reading": { 840 | "type": "array", 841 | "description": "The 'reading' section describes the candidate's reading habits and is intended to demonstrate familiarity with industry-relevant publications, blogs, books, or other media that a competent industry candidate should be expected to know.", 842 | "additionalItems": false, 843 | "items": { 844 | "type": "object", 845 | "additionalProperties": true, 846 | "required": ["title"], 847 | "properties": { 848 | 849 | "title": { 850 | "type": "string", 851 | "description": "Title of the book, blog, or article." 852 | }, 853 | 854 | "category": { 855 | "type": "string", 856 | "description": "The type of reading: book, article, blog, magazine, series, etc." 857 | }, 858 | 859 | "url": { 860 | "type": "string", 861 | "description": "URL of the book, blog, or article.", 862 | "format": "uri" 863 | }, 864 | 865 | "author": { 866 | "type": ["string","array"], 867 | "additionalItems": false, 868 | "description": "Author of the book, blog, or article.", 869 | "items": { 870 | "type": "string", 871 | "description": "Author name." 872 | } 873 | }, 874 | 875 | "date": { 876 | "type": "string", 877 | "format": "date", 878 | "description": "Publication date in YYYY, YYYY-MM, or YYYY-MM-DD format." 879 | }, 880 | 881 | "summary": { 882 | "type": "string", 883 | "description": "A brief description of the book, magazine, etc." 884 | } 885 | 886 | } 887 | } 888 | }, 889 | 890 | 891 | 892 | "speaking": { 893 | "type": "array", 894 | "additionalItems": false, 895 | "section": "The 'speaking' section describes the candidate's speaking engagements and presentations.", 896 | "items": { 897 | "type": "object", 898 | "additionalProperties": true, 899 | "required": ["event"], 900 | "properties": { 901 | "title": { 902 | "type": "string", 903 | "description": "Speaking engagement title." 904 | }, 905 | "event": { 906 | "type": "string", 907 | "description": "Event at which you presented." 908 | }, 909 | "location": { 910 | "type": "string", 911 | "description": "Freeform location of the event, e.g., 'San Francisco, CA' or 'Tokyo'." 912 | }, 913 | "date": { 914 | "type": "string", 915 | "description": "Presentation date.", 916 | "format": "date" 917 | }, 918 | "highlights": { 919 | "type": "array", 920 | "description": "Noteworthy achievements and/or highlights.", 921 | "additionalItems": false, 922 | "items": { 923 | "type": "string", 924 | "description": "An array of specific highlights such as 'Won 'Best Speaker' award at 2012 E3 expo'." 925 | } 926 | }, 927 | "keywords": { 928 | "type": "array", 929 | "description": "Keywords associated with this speaking engagement.", 930 | "additionalItems": false, 931 | "items": { 932 | "type": "string", 933 | "description": "A list of keywords such as 'TED', 'E3', 'mathematics', 'Big Data', etc." 934 | } 935 | }, 936 | "summary": { 937 | "type": "string", 938 | "description": "A description of this speaking engagement." 939 | } 940 | } 941 | } 942 | }, 943 | 944 | 945 | 946 | "governance": { 947 | "type": "array", 948 | "additionalItems": false, 949 | "description": "The 'governance' section describes the candidate's leadership, standards, board, and committee roles.", 950 | "items": { 951 | "type": "object", 952 | "additionalProperties": true, 953 | "required": ["organization"], 954 | "properties": { 955 | 956 | "summary": { 957 | "type": "string", 958 | "description": "Summary of your governance at this organization." 959 | }, 960 | 961 | "category": { 962 | "type": "string", 963 | "description": "Type of governance: committee, board, standards group, etc." 964 | }, 965 | 966 | "role": { 967 | "type": "string", 968 | "description": "Governance role: board member, contributor, director, etc." 969 | }, 970 | 971 | "organization": { 972 | "type": "string", 973 | "description": "The organization." 974 | }, 975 | 976 | "start": { 977 | "type": "string", 978 | "description": "Start date.", 979 | "format": "date" 980 | }, 981 | 982 | "end": { 983 | "type": "string", 984 | "description": "End date.", 985 | "format": "date" 986 | }, 987 | 988 | "keywords": { 989 | "type": "array", 990 | "description": "Keywords associated with this governance stint.", 991 | "additionalItems": false, 992 | "items": { 993 | "type": "string", 994 | "description": "For example, C++, CRM, HIPAA." 995 | } 996 | }, 997 | 998 | "highlights": { 999 | "type": "array", 1000 | "description": "Noteworthy achievements and/or highlights.", 1001 | "additionalItems": false, 1002 | "items": { 1003 | "type": "string", 1004 | "description": "For ex, 'Increased company profits by 35% year over year'." 1005 | } 1006 | } 1007 | 1008 | } 1009 | } 1010 | }, 1011 | 1012 | 1013 | 1014 | "languages": { 1015 | "type": "array", 1016 | "description": "The 'languages' section describes the candidate's knowledge of world languages such as English, Spanish, or Chinese.", 1017 | "additionalItems": false, 1018 | "items": { 1019 | "type": "object", 1020 | "additionalProperties": true, 1021 | "required": ["language"], 1022 | "properties": { 1023 | 1024 | "language": { 1025 | "type": "string", 1026 | "description": "The name of the language: English, Spanish, etc." 1027 | }, 1028 | 1029 | "level": { 1030 | "type": "string", 1031 | "description": "Level of fluency with the language, from 1 to 10." 1032 | }, 1033 | 1034 | "years": { 1035 | "type": ["string","number"], 1036 | "description": "Amount of time language spoken?" 1037 | } 1038 | } 1039 | } 1040 | }, 1041 | 1042 | 1043 | 1044 | "samples": { 1045 | "type": "array", 1046 | "description": "The 'samples' section provides an accessible demonstration of the candidate's portfolio or work product to potential employers and co-workers.", 1047 | "additionalItems": false, 1048 | "items": { 1049 | "type": "object", 1050 | "additionalProperties": true, 1051 | "required": ["title"], 1052 | "properties": { 1053 | 1054 | "title": { 1055 | "type": "string", 1056 | "description": "Title or descriptive name." 1057 | }, 1058 | 1059 | "summary": { 1060 | "type": "string", 1061 | "description": "A brief description of the sample." 1062 | }, 1063 | 1064 | "url": { 1065 | "type": "string", 1066 | "description": "URL of the sample (if any).", 1067 | "format": "uri" 1068 | }, 1069 | 1070 | "date": { 1071 | "type": "string", 1072 | "description": "Date the sample was released in YYYY, YYYY-MM, or YYYY-MM-DD format.", 1073 | "format": "date" 1074 | }, 1075 | 1076 | "highlights": { 1077 | "type": "array", 1078 | "description": "Noteworthy achievements and/or highlights for this sample.", 1079 | "additionalItems": false, 1080 | "items": { 1081 | "type": "string", 1082 | "description": "For ex, 'Implemented optimized search algorithm dervied from Slices of Pi'." 1083 | } 1084 | }, 1085 | 1086 | "keywords": { 1087 | "type": "array", 1088 | "description": "Keywords associated with this work sample.", 1089 | "additionalItems": false, 1090 | "items": { 1091 | "type": "string", 1092 | "description": "For example, C++, HTML, game." 1093 | } 1094 | } 1095 | 1096 | } 1097 | } 1098 | }, 1099 | 1100 | 1101 | 1102 | "references": { 1103 | "type": "array", 1104 | "description": "The 'references' section describes the candidate's personal, professional, and/or technical references.", 1105 | "additionalItems": false, 1106 | "items": { 1107 | "type": "object", 1108 | "additionalProperties": true, 1109 | "required": ["name"], 1110 | "properties": { 1111 | 1112 | "name": { 1113 | "type": "string", 1114 | "description": "The full name of the person giving the reference." 1115 | }, 1116 | 1117 | "role": { 1118 | "type": "string", 1119 | "description": "The occupation of this reference, or his or her relationship to the candidate." 1120 | }, 1121 | 1122 | "category": { 1123 | "type": "string", 1124 | "description": "The type of reference, eg, professional, technical, or personal." 1125 | }, 1126 | 1127 | "private": { 1128 | "type": "boolean", 1129 | "description": "Is this a private reference?" 1130 | }, 1131 | 1132 | "summary": { 1133 | "type": "string", 1134 | "description": "Optional summary information for this reference." 1135 | }, 1136 | 1137 | "contact": { 1138 | "type": "array", 1139 | "additionalItems": false, 1140 | "items": { 1141 | "type": "object", 1142 | "additionalProperties": true, 1143 | "properties": { 1144 | 1145 | "label": { 1146 | "type": "string", 1147 | "description": "Friendly label for this piece of contact info." 1148 | }, 1149 | 1150 | "category": { 1151 | "type": "string", 1152 | "description": "Type of contact information (phone, email, web, etc.)." 1153 | }, 1154 | 1155 | "value": { 1156 | "type": "string", 1157 | "description": "The email address, phone number, etc." 1158 | } 1159 | } 1160 | } 1161 | } 1162 | } 1163 | } 1164 | }, 1165 | 1166 | 1167 | 1168 | "testimonials": { 1169 | "type": "array", 1170 | "description": "The 'testimonials' section contains public testimonials of the candidate's professionalism and character.", 1171 | "additionalItems": false, 1172 | "items": { 1173 | "type": "object", 1174 | "additionalProperties": true, 1175 | "required": ["name", "quote"], 1176 | "properties": { 1177 | 1178 | "name": { 1179 | "type": "string", 1180 | "description": "The full name of the person giving the reference." 1181 | }, 1182 | 1183 | "quote": { 1184 | "type": "string", 1185 | "description": "A quoted reference, eg, 'Susan was an excellent team leader, manager, and operations specialist with a great eye for detail. I'd gladly hire her again if I could!'" 1186 | }, 1187 | 1188 | "category": { 1189 | "type": "string", 1190 | "description": "Type of reference: personal, professional, or technical." 1191 | }, 1192 | 1193 | "private": { 1194 | "type": "boolean", 1195 | "description": "Public reference (testimonial) or via private contact?" 1196 | } 1197 | } 1198 | } 1199 | }, 1200 | 1201 | 1202 | 1203 | "interests": { 1204 | "type": "array", 1205 | "additionalItems": false, 1206 | "description": "The 'interests' section provides a sampling of the candidate's interests and enthusiasms outside of work.", 1207 | "items": { 1208 | "type": "object", 1209 | "additionalProperties": true, 1210 | "required": ["name"], 1211 | "properties": { 1212 | 1213 | "name": { 1214 | "type": "string", 1215 | "description": "The name or title of the interest or hobby." 1216 | }, 1217 | 1218 | "summary": { 1219 | "type": "string" 1220 | }, 1221 | 1222 | "keywords": { 1223 | "type": "array", 1224 | "additionalItems": false, 1225 | "description": "Keywords associated with this interest.", 1226 | "items": { 1227 | "type": "string", 1228 | "description": "A keyword relating to this interest." 1229 | } 1230 | } 1231 | } 1232 | } 1233 | }, 1234 | 1235 | 1236 | 1237 | "extracurricular": { 1238 | "type": "array", 1239 | "description": "The 'extracurricular' section describes the candidate's involvement with industry-related events and enterprises outside of work. For example: attending conferences, workshops, or meetups.", 1240 | "additionalItems": false, 1241 | "items": { 1242 | "type": "object", 1243 | "additionalProperties": true, 1244 | "required": ["title", "activity"], 1245 | "properties": { 1246 | "title": { 1247 | "type": "string", 1248 | "description": "Title of the extracurricular activity." 1249 | }, 1250 | "activity": { 1251 | "type": "string", 1252 | "description": "The extracurricular activity." 1253 | }, 1254 | "location": { 1255 | "type": "string", 1256 | "description": "City, state, or other freeform location." 1257 | }, 1258 | "start": { 1259 | "type": "string", 1260 | "description": "Start date.", 1261 | "format": "date" 1262 | }, 1263 | "end": { 1264 | "type": "string", 1265 | "description": "End date.", 1266 | "format": "date" 1267 | } 1268 | } 1269 | } 1270 | }, 1271 | 1272 | 1273 | 1274 | "affiliation": { 1275 | "type": "object", 1276 | "additionalProperties": true, 1277 | "description": "The 'affiliation' section describes the candidate's membership in groups, clubs, organizations, and professional associations whether at the collegiate, corporate, or personal level.", 1278 | "properties": { 1279 | 1280 | "summary": { 1281 | "type": "string", 1282 | "description": "Optional summary of overall affiliation and membership experience." 1283 | }, 1284 | 1285 | "history": { 1286 | "type": "array", 1287 | "additionalItems": false, 1288 | "items": { 1289 | "type": "object", 1290 | "additionalProperties": true, 1291 | "required": ["organization"], 1292 | "properties": { 1293 | 1294 | "category": { 1295 | "type": "string", 1296 | "description": "The type of affiliation: club, union, meetup, etc." 1297 | }, 1298 | 1299 | "organization": { 1300 | "type": "string", 1301 | "description": "The name of the organization or group." 1302 | }, 1303 | 1304 | "role": { 1305 | "type": "string", 1306 | "description": "Your role in the organization or group." 1307 | }, 1308 | 1309 | "url": { 1310 | "type": "string", 1311 | "description": "Organization website.", 1312 | "format": "uri" 1313 | }, 1314 | 1315 | "start": { 1316 | "type": "string", 1317 | "description": "Date your affiliation with the organization began, in YYYY, YYYY-MM, or YYYY-MM-DD format.", 1318 | "format": "date" 1319 | }, 1320 | 1321 | "end": { 1322 | "type": "string", 1323 | "description": "Date your affiliation with the organization ended, in YYYY, YYYY-MM, or YYYY-MM-DD format.", 1324 | "format": "date" 1325 | }, 1326 | 1327 | "summary": { 1328 | "type": "string", 1329 | "description": "A summary of your achievements and responsibilities during this affiliation." 1330 | }, 1331 | 1332 | "highlights": { 1333 | "type": "array", 1334 | "description": "Noteworthy achievements and/or highlights.", 1335 | "additionalItems": false, 1336 | "items": { 1337 | "type": "string", 1338 | "description": "For ex, 'Served on board of directors of national non-profit organization with 20,000 members.'." 1339 | } 1340 | }, 1341 | 1342 | "keywords": { 1343 | "type": "array", 1344 | "description": "Keywords associated with this affiliation.", 1345 | "additionalItems": false, 1346 | "items": { 1347 | "type": "string", 1348 | "description": "For example, C++, CRM, HIPAA." 1349 | } 1350 | }, 1351 | 1352 | "location": { 1353 | "type": "string", 1354 | "description": "Freeform location of the position, e.g., 'San Francisco, CA' or 'Tokyo'." 1355 | } 1356 | } 1357 | } 1358 | } 1359 | 1360 | } 1361 | } 1362 | 1363 | } 1364 | } 1365 | --------------------------------------------------------------------------------