diff --git a/.gitignore b/.gitignore index 3d972f0..ce751fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ tests/sandbox/ +docs/ diff --git a/Gruntfile.js b/Gruntfile.js index 56169c7..90941b6 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -15,15 +15,33 @@ module.exports = function (grunt) { reporter: 'spec' }, all: { src: ['tests/*.js'] } + }, + + yuidoc: { + compile: { + name: '<%= pkg.name %>', + description: '<%= pkg.description %>', + version: '<%= pkg.version %>', + url: '<%= pkg.homepage %>', + options: { + paths: 'src/', + //themedir: 'path/to/custom/theme/', + outdir: 'docs/' + } + } } }; grunt.initConfig( opts ); grunt.loadNpmTasks('grunt-simple-mocha'); - grunt.registerTask('test', 'Test the FluentLib library.', function( config ) { + grunt.loadNpmTasks('grunt-contrib-yuidoc'); + grunt.registerTask('test', 'Test the FluentCV library.', function( config ) { grunt.task.run( ['simplemocha:all'] ); }); - grunt.registerTask('default', [ 'test' ]); + grunt.registerTask('document', 'Generate FluentCV library documentation.', function( config ) { + grunt.task.run( ['yuidoc'] ); + }); + grunt.registerTask('default', [ 'test', 'yuidoc' ]); }; diff --git a/README.md b/README.md index ea2d501..704c16e 100644 --- a/README.md +++ b/README.md @@ -2,63 +2,133 @@ fluentCV ======== *Generate beautiful, targeted resumes from your command line or shell.* -FluentCV is a **hackable, data-driven, dev-friendly resume authoring tool** with support for HTML, Markdown, Word, PDF, plain text, smoke signal, carrier pigeon, and other arbitrary-format resumes and CVs. - ![](assets/fluentcv_cli_ubuntu.png) -Looking for a desktop GUI version with pretty timelines and graphs? Check out [FluentCV Desktop][7]. +FluentCV is a Swiss Army knife for resumes and CVs. Use it to: + +1. **Generate** polished multiformat resumes in HTML, Word, Markdown, PDF, plain +text, JSON, and YAML formats—without violating DRY. +2. **Convert** resumes between [FRESH][fresca] and [JSON Resume][6] formats. +3. **Validate** resumes against either format. + +Install it with NPM: + +```bash +[sudo] npm install fluentcv -g +``` + +Note: for PDF generation you'll need to install a copy of [wkhtmltopdf][3] for +your platform. ## Features - Runs on OS X, Linux, and Windows. -- Store your resume data as a durable, versionable JSON, YML, or XML document. -- Generate multiple targeted resumes in multiple formats, based on your needs. -- Output to HTML, PDF, Markdown, Word, JSON, YAML, XML, or a custom format. -- Never update one piece of information in four different resumes again. -- Compatible with the [JSON Resume standard][6] and [authoring tools][7]. +- Store your resume data as a durable, versionable JSON or YAML document. +- Generate polished resumes in multiple formats without violating [DRY][dry]. +- Output to HTML, PDF, Markdown, MS Word, JSON, YAML, plain text, or XML. +- Compatible with [FRESH][fresh], [JSON Resume][6], [FRESCA][fresca], and +[FCV Desktop][7]. +- Validate resumes against the FRESH or JSON Resume schema. +- Support for multiple input and output resumes. - Free and open-source through the MIT license. - Forthcoming: StackOverflow and LinkedIn support. -- Forthcoming: More themes! +- Forthcoming: More commands and themes. + +Looking for a desktop GUI version for Windows / OS X / Linux? Check out +[FluentCV Desktop][7]. + +## Getting Started + +To use FluentCV you'll need to create a valid resume in either [FRESH][fresca] +or [JSON Resume][6] format. Then you can start using the command line tool. +There are three commands you should be aware of: + +- `build` generates resumes in HTML, Word, Markdown, PDF, and other formats. Use +it when you need to submit, upload, print, or email resumes in specific formats. + + ```bash + # fluentcv BUILD TO [-t THEME] + fluentcv BUILD resume.json TO out/resume.all + fluentcv BUILD r1.json r2.json TO out/rez.html out/rez.md foo/rez.all + ``` + +- `convert` converts your source resume between FRESH and JSON Resume formats. +Use it to convert between the two formats to take advantage of tools and services. + + ```bash + # fluentcv CONVERT TO + fluentcv CONVERT resume.json TO resume-jrs.json + fluentcv CONVERT 1.json 2.json 3.json TO out/1.json out/2.json out/3.json + ``` + +- `validate` validates the specified resume against either the FRESH or JSON +Resume schema. Use it to make sure your resume data is sufficient and complete. + + ```bash + # fluentcv VALIDATE + fluentcv VALIDATE resume.json + fluentcv VALIDATE r1.json r2.json r3.json + ``` + +## Supported Output Formats + +FluentCV supports these output formats: + +Output Format | Ext | Notes +------------- | --- | ----- +HTML | .html | A standard HTML 5 + CSS resume format that can be viewed in a browser, deployed to a website, etc. +Markdown | .md | A structured Markdown document that can be used as-is or used to generate HTML. +MS Word | .doc | A Microsoft Word office document. +Adobe Acrobat (PDF) | .pdf | A binary PDF document driven by an HTML theme. +plain text | .txt | A formatted plain text document appropriate for emails or copy-paste. +JSON | .json | A JSON representation of the resume. +YAML | .yml | A YAML representation of the resume. +RTF | .rtf | Forthcoming. +Textile | .textile | Forthcoming. +image | .png, .bmp | Forthcoming. ## Install FluentCV requires a recent version of [Node.js][4] and [NPM][5]. Then: -1. (Optional, for PDF support) Install the latest official [wkhtmltopdf][3] binary for your platform. -2. Install **fluentCV** by running `npm install fluentcv -g`. +1. Install the latest official [wkhtmltopdf][3] binary for your platform. +2. Install **fluentCV** with `[sudo] npm install fluentcv -g`. 3. You're ready to go. ## Use -Assuming you've got a JSON-formatted resume handy, generating resumes in different formats and combinations easy. Just run: +Assuming you've got a JSON-formatted resume handy, generating resumes in +different formats and combinations easy. Just run: ```bash -fluentcv [inputs] [outputs] [-t theme]. +fluentcv BUILD [-t theme]. ``` -Where `[inputs]` is one or more .json resume files, separated by spaces; `[outputs]` is one or more destination resumes, each prefaced with the `-o` option; and `[theme]` is the desired theme. For example: +Where `` is one or more .json resume files, separated by spaces; +`` is one or more destination resumes, and `` is the desired +theme (default to Modern). For example: ```bash # Generate all resume formats (HTML, PDF, DOC, TXT, YML, etc.) -fluentcv resume.json -o out/resume.all -t modern +fluentcv build resume.json -o out/resume.all -t modern # Generate a specific resume format -fluentcv resume.json -o out/resume.html -fluentcv resume.json -o out/resume.pdf -fluentcv resume.json -o out/resume.md -fluentcv resume.json -o out/resume.doc -fluentcv resume.json -o out/resume.json -fluentcv resume.json -o out/resume.txt -fluentcv resume.json -o out/resume.yml +fluentcv build resume.json TO out/resume.html +fluentcv build resume.json TO out/resume.pdf +fluentcv build resume.json TO out/resume.md +fluentcv build resume.json TO out/resume.doc +fluentcv build resume.json TO out/resume.json +fluentcv build resume.json TO out/resume.txt +fluentcv build resume.json TO out/resume.yml # Specify 2 inputs and 3 outputs -fluentcv in1.json in2.json -o out.html -o out.doc -o out.pdf +fluentcv build in1.json in2.json TO out.html out.doc out.pdf ``` You should see something to the effect of: ``` -*** FluentCV v0.7.2 *** +*** FluentCV v0.9.0 *** Reading JSON resume: foo/resume.json Applying MODERN Theme (7 formats) Generating HTML resume: out/resume.html @@ -77,11 +147,11 @@ Generating YAML resume: out/resume.yml You can specify a predefined or custom theme via the optional `-t` parameter. For a predefined theme, include the theme name. For a custom theme, include the path to the custom theme's folder. ```bash -fluentcv resume.json -t modern -fluentcv resume.json -t ~/foo/bar/my-custom-theme/ +fluentcv build resume.json -t modern +fluentcv build resume.json -t ~/foo/bar/my-custom-theme/ ``` -As of v0.7.2, available predefined themes are `modern`, `minimist`, and `hello-world`, and `compact`. +As of v0.9.0, available predefined themes are `modern`, `minimist`, and `hello-world`, and `compact`. ### Merging resumes @@ -89,13 +159,13 @@ You can **merge multiple resumes together** by specifying them in order from mos ```bash # Merge specific.json onto base.json and generate all formats -fluentcv base.json specific.json -o resume.all +fluentcv build base.json specific.json -o resume.all ``` This can be useful for overriding a base (generic) resume with information from a specific (targeted) resume. For example, you might override your generic catch-all "software developer" resume with specific details from your targeted "game developer" resume, or combine two partial resumes into a "complete" resume. Merging follows conventional [extend()][9]-style behavior and there's no arbitrary limit to how many resumes you can merge: ```bash -fluentcv in1.json in2.json in3.json in4.json -o out.html -o out.doc +fluentcv build in1.json in2.json in3.json in4.json TO out.html out.doc Reading JSON resume: in1.json Reading JSON resume: in2.json Reading JSON resume: in3.json @@ -111,14 +181,14 @@ You can specify **multiple output targets** and FluentCV will build them: ```bash # Generate out1.doc, out1.pdf, and foo.txt from me.json. -fluentcv me.json -o out1.doc -o out1.pdf -o foo.txt +fluentcv build me.json -o out1.doc -o out1.pdf -o foo.txt ``` You can also omit the output file(s) and/or theme completely: ```bash # Equivalent to "fluentcv resume.json resume.all -t modern" -fluentcv resume.json +fluentcv build resume.json ``` ### Using .all @@ -127,17 +197,52 @@ The special `.all` extension tells FluentCV to generate all supported output for ```bash # Generate all resume formats (HTML, PDF, DOC, TXT, etc.) -fluentcv me.json -o out/resume.all +fluentcv build me.json -o out/resume.all ``` ..tells FluentCV to read `me.json` and generate `out/resume.md`, `out/resume.doc`, `out/resume.html`, `out/resume.txt`, `out/resume.pdf`, and `out/resume.json`. -### Prettifying +### Validating -FluentCV applies [js-beautify][10]-style HTML prettification by default to HTML-formatted resumes. To disable prettification, the `--nopretty` or `-n` flag can be used: +FluentCV can also validate your resumes against either the [FRESH / +FRESCA][fresca] or [JSON Resume][6] formats. To validate one or more existing +resumes, use the `validate` command: ```bash -fluentcv resume.json out.all --nopretty +# Validate myresume.json against either the FRESH or JSON Resume schema. +fluentcv validate resumeA.json resumeB.json +``` + +FluentCV will validate each specified resume in turn: + +```bash +*** FluentCV v0.9.0 *** +Validating JSON resume: resumeA.json (INVALID) +Validating JSON resume: resumeB.json (VALID) +``` + +### Converting + +FluentCV can convert between the [FRESH][fresca] and [JSON Resume][6] formats. +Just run: + +```bash +fluentcv CONVERT +``` + +where is one or more resumes in FRESH or JSON Resume format, and + is a corresponding list of output file names. FluentCV will autodetect +the format (FRESH or JRS) of each input resume and convert it to the other +format (JRS or FRESH). + +### Prettifying + +FluentCV applies [js-beautify][10]-style HTML prettification by default to +HTML-formatted resumes. To disable prettification, the `--nopretty` or `-n` flag +can be used: + +```bash +fluentcv generate resume.json out.all --nopretty ``` ### Silent Mode @@ -145,8 +250,8 @@ fluentcv resume.json out.all --nopretty Use `-s` or `--silent` to run in silent mode: ```bash -fluentcv resume.json -o someFile.all -s -fluentcv resume.json -o someFile.all --silent +fluentcv generate resume.json -o someFile.all -s +fluentcv generate resume.json -o someFile.all --silent ``` ## License @@ -163,3 +268,6 @@ MIT. Go crazy. See [LICENSE.md][1] for details. [8]: https://youtu.be/N9wsjroVlu8 [9]: https://api.jquery.com/jquery.extend/ [10]: https://github.com/beautify-web/js-beautify +[fresh]: https://github.com/fluentdesk/FRESH +[fresca]: https://github.com/fluentdesk/FRESCA +[dry]: https://en.wikipedia.org/wiki/Don%27t_repeat_yourself diff --git a/assets/fluentcv_cli_ubuntu.png b/assets/fluentcv_cli_ubuntu.png index 338af47..f4db94c 100644 Binary files a/assets/fluentcv_cli_ubuntu.png and b/assets/fluentcv_cli_ubuntu.png differ diff --git a/package.json b/package.json index b07f9ce..cb0287a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fluentcv", - "version": "0.8.0", + "version": "0.9.0", "description": "Generate beautiful, targeted resumes from your command line, shell, or in Javascript with Node.js.", "repository": { "type": "git", @@ -10,7 +10,15 @@ "resume", "CV", "portfolio", - "Markdown" + "employment", + "career", + "Markdown", + "JSON", + "Word", + "PDF", + "YAML", + "HTML", + "CLI" ], "author": "James M. Devlin", "license": "MIT", @@ -24,7 +32,9 @@ }, "homepage": "https://github.com/fluentdesk/fluentcv", "dependencies": { - "fluent-themes": "0.4.0-beta", + "FRESCA": "fluentdesk/FRESCA#v0.1.0", + "colors": "^1.1.2", + "fluent-themes": "0.5.0-beta", "fs-extra": "^0.24.0", "html": "0.0.10", "is-my-json-valid": "^2.12.2", @@ -41,6 +51,7 @@ "devDependencies": { "chai": "*", "grunt": "*", + "grunt-contrib-yuidoc": "^0.10.0", "grunt-simple-mocha": "*", "is-my-json-valid": "^2.12.2", "mocha": "*", diff --git a/src/core/convert.js b/src/core/convert.js new file mode 100644 index 0000000..f16aafc --- /dev/null +++ b/src/core/convert.js @@ -0,0 +1,291 @@ +/** +FRESH to JSON Resume conversion routiens. +@license MIT. Copyright (c) 2015 James M. Devlin / FluentDesk +*/ + +(function(){ + + /** + Convert between FRESH and JRS resume/CV formats. + @class FRESHConverter + */ + var FRESHConverter = module.exports = { + + + /** + Convert from JSON Resume format to FRESH. + */ + toFRESH: function( src, foreign ) { + + foreign = (foreign === undefined || foreign === null) ? true : foreign; + + return { + + name: src.basics.name, + + info: { + label: src.basics.label, + class: src.basics.class, // <--> round-trip + image: src.basics.picture, + brief: src.basics.summary + }, + + contact: { + email: src.basics.email, + phone: src.basics.phone, + website: src.basics.website, + other: src.basics.other // <--> round-trip + }, + + meta: meta( true, src.meta ), + + location: { + city: src.basics.location.city, + region: src.basics.location.region, + country: src.basics.location.countryCode, + code: src.basics.location.postalCode, + address: src.basics.location.address + }, + + employment: { + history: src.work.map( function( job ) { + return { + position: job.position, + employer: job.company, + summary: job.summary, + current: (!job.endDate || !job.endDate.trim() || job.endDate.trim().toLowerCase() === 'current') || undefined, + start: job.startDate, + end: job.endDate, + url: job.website, + keywords: "", + highlights: job.highlights + }; + }) + }, + + education: { + history: src.education.map(function(edu){ + return { + institution: edu.institution, + start: edu.startDate, + end: edu.endDate, + grade: edu.gpa, + curriculum: edu.courses, + url: edu.website || edu.url || null, + summary: null, + area: edu.area, + studyType: edu.studyType + }; + }) + }, + + service: { + history: src.volunteer.map(function(vol) { + return { + type: 'volunteer', + position: vol.position, + organization: vol.organization, + start: vol.startDate, + end: vol.endDate, + url: vol.website, + summary: vol.summary, + highlights: vol.highlights + }; + }) + }, + + skills: skillsToFRESH( src.skills ), + + writing: src.publications.map(function(pub){ + return { + title: pub.name, + flavor: undefined, + publisher: pub.publisher, + url: pub.website, + date: pub.releaseDate, + summary: pub.summary + }; + }), + + recognition: src.awards.map(function(awd){ + return { + title: awd.title, + date: awd.date, + summary: awd.summary, + from: awd.awarder, + url: null + }; + }), + + social: src.basics.profiles.map(function(pro){ + return { + label: pro.network, + network: pro.network, + url: pro.url, + user: pro.username + }; + }), + + interests: src.interests, + references: src.references, + languages: src.languages, + disposition: src.disposition // <--> round-trip + }; + }, + + /** + Convert from FRESH format to JSON Resume. + @param foreign True if non-JSON-Resume properties should be included in + the result, false if those properties should be excluded. + */ + toJRS: function( src, foreign ) { + + foreign = (foreign === undefined || foreign === null) ? false : foreign; + + return { + + basics: { + name: src.name, + label: src.info.label, + class: foreign ? src.info.class : undefined, + summary: src.info.brief, + website: src.contact.website, + phone: src.contact.phone, + email: src.contact.email, + picture: src.info.image, + location: { + address: src.location.address, + postalCode: src.location.code, + city: src.location.city, + countryCode: src.location.country, + region: src.location.region + }, + profiles: src.social.map(function(soc){ + return { + network: soc.network, + username: soc.user, + url: soc.url + }; + }) + }, + + work: src.employment.history.map(function(emp){ + return { + company: emp.employer, + website: emp.url, + position: emp.position, + startDate: emp.start, + endDate: emp.end, + summary: emp.summary, + highlights: emp.highlights + }; + }), + + education: src.education.history.map(function(edu){ + return { + institution: edu.institution, + gpa: edu.grade, + courses: edu.curriculum, + startDate: edu.start, + endDate: edu.end, + area: edu.area, + studyType: edu.studyType + }; + }), + + skills: skillsToJRS( src.skills ), + + volunteer: src.service.history.map(function(srv){ + return { + flavor: foreign ? srv.flavor : undefined, + organization: srv.organization, + position: srv.position, + startDate: srv.start, + endDate: srv.end, + website: srv.url, + summary: srv.summary, + highlights: srv.highlights + }; + }), + + awards: src.recognition.map(function(awd){ + return { + flavor: foreign ? awd.flavor : undefined, + url: foreign ? awd.url: undefined, + title: awd.title, + date: awd.date, + awarder: awd.from, + summary: awd.summary + }; + }), + + publications: src.writing.map(function(pub){ + return { + name: pub.title, + publisher: pub.publisher, + releaseDate: pub.date, + website: pub.url, + summary: pub.summary + }; + }), + + interests: src.interests, + references: src.references, + samples: foreign ? src.samples : undefined, + disposition: foreign ? src.disposition : undefined, + languages: src.languages + + }; + + } + + }; + + function meta( direction, obj ) { + if( direction ) { + obj = obj || { }; + obj.format = obj.format || "FRESH@0.1.0"; + obj.version = obj.version || "0.1.0"; + } + return obj; + } + + function skillsToFRESH( skills ) { + + return { + sets: skills.map(function(set) { + return { + name: set.name, + level: set.level, + skills: set.keywords + }; + }) + }; + } + + function skillsToJRS( skills ) { + var ret = []; + if( skills.sets && skills.sets.length ) { + ret = skills.sets.map(function(set){ + return { + name: set.name, + level: set.level, + keywords: set.skills + }; + }); + } + else if( skills.list ) { + ret = skills.list.map(function(sk){ + return { + name: sk.name, + level: sk.level, + keywords: sk.keywords + }; + }); + } + return ret; + } + + + +}()); diff --git a/src/core/fresh-resume.js b/src/core/fresh-resume.js new file mode 100644 index 0000000..fafe024 --- /dev/null +++ b/src/core/fresh-resume.js @@ -0,0 +1,310 @@ +/** +Definition of the FRESHResume class. +@license MIT. Copyright (c) 2015 James M. Devlin / FluentDesk +*/ + +(function() { + + var FS = require('fs') + , extend = require('../utils/extend') + , validator = require('is-my-json-valid') + , _ = require('underscore') + , PATH = require('path') + , moment = require('moment') + , CONVERTER = require('./convert'); + + /** + A FRESH-style resume in JSON or YAML. + @class FreshResume + */ + function FreshResume() { + + } + + /** + Open and parse the specified FRESH resume sheet. Merge the JSON object model + onto this Sheet instance with extend() and convert sheet dates to a safe & + consistent format. Then sort each section by startDate descending. + */ + FreshResume.prototype.open = function( file, title ) { + this.imp = { fileName: file }; + this.imp.raw = FS.readFileSync( file, 'utf8' ); + return this.parse( this.imp.raw, title ); + }; + + /** + Save the sheet to disk (for environments that have disk access). + */ + FreshResume.prototype.save = function( filename ) { + this.imp.fileName = filename || this.imp.fileName; + FS.writeFileSync( this.imp.fileName, this.stringify(), 'utf8' ); + return this; + }; + + /** + Save the sheet to disk in a specific format, either FRESH or JSON Resume. + */ + FreshResume.prototype.saveAs = function( filename, format ) { + this.imp.fileName = filename || this.imp.fileName; + if( format !== 'JRS' ) { + FS.writeFileSync( this.imp.fileName, this.stringify(), 'utf8' ); + } + else { + var newRep = CONVERTER.toJRS( this ); + FS.writeFileSync( this.imp.fileName, FreshResume.stringify( newRep ), 'utf8' ); + } + return this; + } + + /** + Convert the supplied object to a JSON string, sanitizing meta-properties along + the way. + */ + FreshResume.stringify = function( obj ) { + function replacer( key,value ) { // Exclude these keys from stringification + return _.some(['imp', 'warnings', 'computed', 'filt', 'ctrl', 'index', + 'safe', 'result', 'isModified', 'htmlPreview', 'display_progress_bar'], + function( val ) { return key.trim() === val; } + ) ? undefined : value; + } + return JSON.stringify( obj, replacer, 2 ); + }, + + /** + Convert this object to a JSON string, sanitizing meta-properties along the + way. Don't override .toString(). + */ + FreshResume.prototype.stringify = function() { + return FreshResume.stringify( this ); + }; + + /** + Open and parse the specified JSON resume sheet. Merge the JSON object model + onto this Sheet instance with extend() and convert sheet dates to a safe & + consistent format. Then sort each section by startDate descending. + */ + FreshResume.prototype.parse = function( stringData, opts ) { + + // Parse the incoming JSON representation + var rep = JSON.parse( stringData ); + + // Convert JSON Resume to FRESH if necessary + if( rep.basics ) { + rep = CONVERTER.toFRESH( rep ); + rep.imp = rep.imp || { }; + rep.imp.orgFormat = 'JRS'; + } + + // Now apply the resume representation onto this object + extend( true, this, rep ); + + // Set up metadata + opts = opts || { }; + if( opts.imp === undefined || opts.imp ) { + this.imp = this.imp || { }; + this.imp.title = (opts.title || this.imp.title) || this.name; + } + // Parse dates, sort dates, and calculate computed values + (opts.date === undefined || opts.date) && _parseDates.call( this ); + (opts.sort === undefined || opts.sort) && this.sort(); + (opts.compute === undefined || opts.compute) && (this.computed = { + numYears: this.duration(), + keywords: this.keywords() + }); + return this; + }; + + /** + Return a unique list of all keywords across all skills. + */ + FreshResume.prototype.keywords = function() { + var flatSkills = []; + this.skills && this.skills.length && + (flatSkills = this.skills.map(function(sk) { return sk.name; })); + return flatSkills; + }, + + /** + Update the sheet's raw data. TODO: remove/refactor + */ + FreshResume.prototype.updateData = function( str ) { + this.clear( false ); + this.parse( str ) + return this; + }; + + /** + Reset the sheet to an empty state. + */ + FreshResume.prototype.clear = function( clearMeta ) { + clearMeta = ((clearMeta === undefined) && true) || clearMeta; + clearMeta && (delete this.imp); + delete this.computed; // Don't use Object.keys() here + delete this.employment; + delete this.service; + delete this.education; + //delete this.awards; + delete this.publications; + //delete this.interests; + delete this.skills; + delete this.social; + }; + + /** + Get the default (empty) sheet. + */ + FreshResume.default = function() { + return new FreshResume().open( PATH.join( __dirname, 'empty.json'), 'Empty' ); + } + + /** + Add work experience to the sheet. + */ + FreshResume.prototype.add = function( moniker ) { + var defSheet = FreshResume.default(); + var newObject = $.extend( true, {}, defSheet[ moniker ][0] ); + this[ moniker ] = this[ moniker ] || []; + this[ moniker ].push( newObject ); + return newObject; + }; + + /** + Determine if the sheet includes a specific social profile (eg, GitHub). + */ + FreshResume.prototype.hasProfile = function( socialNetwork ) { + socialNetwork = socialNetwork.trim().toLowerCase(); + return this.social && _.some( this.social, function(p) { + return p.network.trim().toLowerCase() === socialNetwork; + }); + }; + + /** + Determine if the sheet includes a specific skill. + */ + FreshResume.prototype.hasSkill = function( skill ) { + skill = skill.trim().toLowerCase(); + return this.skills && _.some( this.skills, function(sk) { + return sk.keywords && _.some( sk.keywords, function(kw) { + return kw.trim().toLowerCase() === skill; + }); + }); + }; + + /** + Validate the sheet against the FRESH Resume schema. + */ + FreshResume.prototype.isValid = function( info ) { + var schemaObj = require('FRESCA'); + var validator = require('is-my-json-valid') + var validate = validator( schemaObj, { // Note [1] + formats: { date: /^\d{4}(?:-(?:0[0-9]{1}|1[0-2]{1})(?:-[0-9]{2})?)?$/ } + }); + var ret = validate( this ); + if( !ret ) { + this.imp = this.imp || { }; + this.imp.validationErrors = validate.errors; + } + return ret; + }; + + /** + Calculate the total duration of the sheet. Assumes this.work has been sorted + by start date descending, perhaps via a call to Sheet.sort(). + @returns The total duration of the sheet's work history, that is, the number + of years between the start date of the earliest job on the resume and the + *latest end date of all jobs in the work history*. This last condition is for + sheets that have overlapping jobs. + */ + FreshResume.prototype.duration = function() { + if( this.employment.history && this.employment.history.length ) { + var careerStart = this.employment.history[ this.employment.history.length - 1].safe.start; + if ((typeof careerStart === 'string' || careerStart instanceof String) && + !careerStart.trim()) + return 0; + var careerLast = _.max( this.employment.history, function( w ) { + return w.safe.end.unix(); + }).safe.end; + return careerLast.diff( careerStart, 'years' ); + } + return 0; + }; + + /** + Sort dated things on the sheet by start date descending. Assumes that dates + on the sheet have been processed with _parseDates(). + */ + FreshResume.prototype.sort = function( ) { + + this.employment.history && this.employment.history.sort( byDateDesc ); + this.education.history && this.education.history.sort( byDateDesc ); + this.service.history && this.service.history.sort( byDateDesc ); + + // this.awards && this.awards.sort( function(a, b) { + // return( a.safeDate.isBefore(b.safeDate) ) ? 1 + // : ( a.safeDate.isAfter(b.safeDate) && -1 ) || 0; + // }); + this.publications && this.publications.sort( function(a, b) { + return( a.safe.date.isBefore(b.safe.date) ) ? 1 + : ( a.safe.date.isAfter(b.safe.date) && -1 ) || 0; + }); + + function byDateDesc(a,b) { + return( a.safe.start.isBefore(b.safe.start) ) ? 1 + : ( a.safe.start.isAfter(b.safe.start) && -1 ) || 0; + } + + }; + + /** + Convert human-friendly dates into formal Moment.js dates for all collections. + We don't want to lose the raw textual date as entered by the user, so we store + the Moment-ified date as a separate property with a prefix of .safe. For ex: + job.startDate is the date as entered by the user. job.safeStartDate is the + parsed Moment.js date that we actually use in processing. + */ + function _parseDates() { + + var _fmt = require('./fluent-date').fmt; + + this.employment.history && this.employment.history.forEach( function(job) { + job.safe = { + start: _fmt( job.start ), + end: _fmt( job.end || 'current' ) + }; + }); + this.education.history && this.education.history.forEach( function(edu) { + edu.safe = { + start: _fmt( edu.start ), + end: _fmt( edu.end || 'current' ) + }; + }); + this.service.history && this.service.history.forEach( function(vol) { + vol.safe = { + start: _fmt( vol.start ), + end: _fmt( vol.end || 'current' ) + }; + }); + this.recognition && this.recognition.forEach( function(rec) { + rec.safe = { + date: _fmt( rec.date ) + }; + }); + this.writing && this.writing.forEach( function(pub) { + pub.safe = { + date: _fmt( pub.date ) + }; + }); + } + + /** + Export the Sheet function/ctor. + */ + module.exports = FreshResume; + +}()); + +// Note 1: Adjust default date validation to allow YYYY and YYYY-MM formats +// in addition to YYYY-MM-DD. The original regex: +// +// /^\d{4}-(?:0[0-9]{1}|1[0-2]{1})-[0-9]{2}$/ +// diff --git a/src/core/sheet.js b/src/core/jrs-resume.js similarity index 81% rename from src/core/sheet.js rename to src/core/jrs-resume.js index 9d8e666..b7fed04 100644 --- a/src/core/sheet.js +++ b/src/core/jrs-resume.js @@ -1,6 +1,6 @@ /** -Abstract character/resume sheet representation. -@license Copyright (c) 2015 by James M. Devlin. All rights reserved. +Definition of the JRSResume class. +@license MIT. Copyright (c) 2015 James M. Devlin / FluentDesk */ (function() { @@ -13,14 +13,14 @@ Abstract character/resume sheet representation. , moment = require('moment'); /** - The Sheet class represent a specific JSON character sheet. When Sheet.open + The JRSResume class represent a specific JSON character sheet. When Sheet.open is called, we merge the loaded JSON sheet properties onto the Sheet instance via extend(), so a full-grown sheet object will have all of the methods here, plus a complement of JSON properties from the backing JSON file. That allows us to treat Sheet objects interchangeably with the loaded JSON model. - @class Sheet + @class JRSResume */ - function Sheet() { + function JRSResume() { } @@ -29,18 +29,18 @@ Abstract character/resume sheet representation. onto this Sheet instance with extend() and convert sheet dates to a safe & consistent format. Then sort each section by startDate descending. */ - Sheet.prototype.open = function( file, title ) { - this.meta = { fileName: file }; - this.meta.raw = FS.readFileSync( file, 'utf8' ); - return this.parse( this.meta.raw, title ); + JRSResume.prototype.open = function( file, title ) { + this.imp = { fileName: file }; + this.imp.raw = FS.readFileSync( file, 'utf8' ); + return this.parse( this.imp.raw, title ); }; /** Save the sheet to disk (for environments that have disk access). */ - Sheet.prototype.save = function( filename ) { - this.meta.fileName = filename || this.meta.fileName; - FS.writeFileSync( this.meta.fileName, this.stringify(), 'utf8' ); + JRSResume.prototype.save = function( filename ) { + this.imp.fileName = filename || this.imp.fileName; + FS.writeFileSync( this.imp.fileName, this.stringify(), 'utf8' ); return this; }; @@ -48,7 +48,7 @@ Abstract character/resume sheet representation. Convert this object to a JSON string, sanitizing meta-properties along the way. Don't override .toString(). */ - Sheet.prototype.stringify = function() { + JRSResume.prototype.stringify = function() { function replacer( key,value ) { // Exclude these keys from stringification return _.some(['meta', 'warnings', 'computed', 'filt', 'ctrl', 'index', 'safeStartDate', 'safeEndDate', 'safeDate', 'safeReleaseDate', 'result', @@ -64,14 +64,14 @@ Abstract character/resume sheet representation. onto this Sheet instance with extend() and convert sheet dates to a safe & consistent format. Then sort each section by startDate descending. */ - Sheet.prototype.parse = function( stringData, opts ) { + JRSResume.prototype.parse = function( stringData, opts ) { opts = opts || { }; var rep = JSON.parse( stringData ); extend( true, this, rep ); // Set up metadata - if( opts.meta === undefined || opts.meta ) { - this.meta = this.meta || { }; - this.meta.title = (opts.title || this.meta.title) || this.basics.name; + if( opts.imp === undefined || opts.imp ) { + this.imp = this.imp || { }; + this.imp.title = (opts.title || this.imp.title) || this.basics.name; } // Parse dates, sort dates, and calculate computed values (opts.date === undefined || opts.date) && _parseDates.call( this ); @@ -86,7 +86,7 @@ Abstract character/resume sheet representation. /** Return a unique list of all keywords across all skills. */ - Sheet.prototype.keywords = function() { + JRSResume.prototype.keywords = function() { var flatSkills = []; if( this.skills && this.skills.length ) { this.skills.forEach( function( s ) { @@ -99,7 +99,7 @@ Abstract character/resume sheet representation. /** Update the sheet's raw data. TODO: remove/refactor */ - Sheet.prototype.updateData = function( str ) { + JRSResume.prototype.updateData = function( str ) { this.clear( false ); this.parse( str ) return this; @@ -108,9 +108,9 @@ Abstract character/resume sheet representation. /** Reset the sheet to an empty state. */ - Sheet.prototype.clear = function( clearMeta ) { + JRSResume.prototype.clear = function( clearMeta ) { clearMeta = ((clearMeta === undefined) && true) || clearMeta; - clearMeta && (delete this.meta); + clearMeta && (delete this.imp); delete this.computed; // Don't use Object.keys() here delete this.work; delete this.volunteer; @@ -125,15 +125,15 @@ Abstract character/resume sheet representation. /** Get the default (empty) sheet. */ - Sheet.default = function() { - return new Sheet().open( PATH.join( __dirname, 'empty.json'), 'Empty' ); + JRSResume.default = function() { + return new JRSResume().open( PATH.join( __dirname, 'empty.json'), 'Empty' ); } /** Add work experience to the sheet. */ - Sheet.prototype.add = function( moniker ) { - var defSheet = Sheet.default(); + JRSResume.prototype.add = function( moniker ) { + var defSheet = JRSResume.default(); var newObject = $.extend( true, {}, defSheet[ moniker ][0] ); this[ moniker ] = this[ moniker ] || []; this[ moniker ].push( newObject ); @@ -143,7 +143,7 @@ Abstract character/resume sheet representation. /** Determine if the sheet includes a specific social profile (eg, GitHub). */ - Sheet.prototype.hasProfile = function( socialNetwork ) { + JRSResume.prototype.hasProfile = function( socialNetwork ) { socialNetwork = socialNetwork.trim().toLowerCase(); return this.basics.profiles && _.some( this.basics.profiles, function(p) { return p.network.trim().toLowerCase() === socialNetwork; @@ -153,7 +153,7 @@ Abstract character/resume sheet representation. /** Determine if the sheet includes a specific skill. */ - Sheet.prototype.hasSkill = function( skill ) { + JRSResume.prototype.hasSkill = function( skill ) { skill = skill.trim().toLowerCase(); return this.skills && _.some( this.skills, function(sk) { return sk.keywords && _.some( sk.keywords, function(kw) { @@ -165,7 +165,7 @@ Abstract character/resume sheet representation. /** Validate the sheet against the JSON Resume schema. */ - Sheet.prototype.isValid = function( ) { // TODO: ↓ fix this path ↓ + JRSResume.prototype.isValid = function( ) { // TODO: ↓ fix this path ↓ var schema = FS.readFileSync( PATH.join( __dirname, 'resume.json' ), 'utf8' ); var schemaObj = JSON.parse( schema ); var validator = require('is-my-json-valid') @@ -181,7 +181,7 @@ Abstract character/resume sheet representation. *latest end date of all jobs in the work history*. This last condition is for sheets that have overlapping jobs. */ - Sheet.prototype.duration = function() { + JRSResume.prototype.duration = function() { if( this.work && this.work.length ) { var careerStart = this.work[ this.work.length - 1].safeStartDate; if ((typeof careerStart === 'string' || careerStart instanceof String) && @@ -199,7 +199,7 @@ Abstract character/resume sheet representation. Sort dated things on the sheet by start date descending. Assumes that dates on the sheet have been processed with _parseDates(). */ - Sheet.prototype.sort = function( ) { + JRSResume.prototype.sort = function( ) { this.work && this.work.sort( byDateDesc ); this.education && this.education.sort( byDateDesc ); @@ -253,8 +253,8 @@ Abstract character/resume sheet representation. } /** - Export the Sheet function/ctor. + Export the JRSResume function/ctor. */ - module.exports = Sheet; + module.exports = JRSResume; }()); diff --git a/src/fluentcmd.js b/src/fluentcmd.js index 92df228..2a329ba 100644 --- a/src/fluentcmd.js +++ b/src/fluentcmd.js @@ -1,6 +1,7 @@ /** Internal resume generation logic for FluentCV. -@license Copyright (c) 2015 | James M. Devlin +@license MIT. Copyright (c) 2015 James M. Devlin / FluentDesk +@module fluentcmd.js */ module.exports = function () { @@ -9,11 +10,12 @@ module.exports = function () { var path = require( 'path' ) , extend = require( './utils/extend' ) , unused = require('./utils/string') - , fs = require('fs') + , FS = require('fs') , _ = require('underscore') , FLUENT = require('./fluentlib') , PATH = require('path') , MKDIRP = require('mkdirp') + //, COLORS = require('colors') , rez, _log, _err; /** @@ -24,7 +26,7 @@ module.exports = function () { @param theme Friendly name of the resume theme. Defaults to "modern". @param logger Optional logging override. */ - function gen( src, dst, opts, logger, errHandler ) { + function generate( src, dst, opts, logger, errHandler ) { _log = logger || console.log; _err = errHandler || error; @@ -35,23 +37,20 @@ module.exports = function () { // Load input resumes... if(!src || !src.length) { throw { fluenterror: 3 }; } - var sheets = src.map( function( res ) { - _log( 'Reading JSON resume: ' + res ); - return (new FLUENT.Sheet()).open( res ); - }); + var sheets = loadSourceResumes( src ); // Merge input resumes... var msg = ''; rez = _.reduceRight( sheets, function( a, b, idx ) { - msg += ((idx == sheets.length - 2) ? 'Merging ' + a.meta.fileName : '') - + ' onto ' + b.meta.fileName; + msg += ((idx == sheets.length - 2) ? 'Merging '.gray + a.imp.fileName : '') + + ' onto '.gray + b.imp.fileName; return extend( true, b, a ); }); msg && _log(msg); - // Load the active theme // Verify the specified theme name/path - var tFolder = PATH.resolve( __dirname, '../node_modules/fluent-themes/themes', _opts.theme ); + var relativeThemeFolder = '../node_modules/fluent-themes/themes'; + var tFolder = PATH.resolve( __dirname, relativeThemeFolder, _opts.theme ); var exists = require('./utils/file-exists'); if (!exists( tFolder )) { tFolder = PATH.resolve( _opts.theme ); @@ -59,18 +58,27 @@ module.exports = function () { throw { fluenterror: 1, data: _opts.theme }; } } + + // Load the theme var theTheme = new FLUENT.Theme().open( tFolder ); _opts.themeObj = theTheme; - _log( 'Applying ' + theTheme.name.toUpperCase() + ' theme (' + Object.keys(theTheme.formats).length + ' formats)' ); + _log( 'Applying '.info + theTheme.name.toUpperCase().infoBold + (' theme (' + + Object.keys(theTheme.formats).length + ' formats)').info ); // Expand output resumes... (can't use map() here) - var targets = []; - var that = this; + var targets = [], that = this; ( (dst && dst.length && dst) || ['resume.all'] ).forEach( function(t) { - var to = path.resolve(t), pa = path.parse(to), fmat = pa.ext || '.all'; + + var to = path.resolve(t), + pa = path.parse(to), + fmat = pa.ext || '.all'; + targets.push.apply(targets, fmat === '.all' ? - Object.keys( theTheme.formats ).map(function(k){ var z = theTheme.formats[k]; return { file: to.replace(/all$/g,z.pre), fmt: z } }) - : [{ file: to, fmt: theTheme.getFormat( fmat.slice(1) ) }]); + Object.keys( theTheme.formats ).map(function(k){ + var z = theTheme.formats[k]; + return { file: to.replace(/all$/g,z.pre), fmt: z } + }) : [{ file: to, fmt: theTheme.getFormat( fmat.slice(1) ) }]); + }); // Run the transformation! @@ -87,14 +95,16 @@ module.exports = function () { */ function single( fi, theme ) { try { - var f = fi.file, fType = fi.fmt.ext, fName = path.basename( f, '.' + fType ); + var f = fi.file, fType = fi.fmt.ext, fName = path.basename(f,'.'+fType); var fObj = _.property( fi.fmt.pre )( theme.formats ); - var fOut = path.join( f.substring( 0, f.lastIndexOf('.') + 1 ) + fObj.pre ); - _log( 'Generating ' + fi.fmt.title.toUpperCase() + ' resume: ' + path.relative(process.cwd(), f ) ); - var theFormat = _fmts.filter( function( fmt ) { - return fmt.name === fi.fmt.pre; - })[0]; - MKDIRP( path.dirname(fOut) ); // Ensure dest folder exists; don't bug user + var fOut = path.join( f.substring( 0, f.lastIndexOf('.')+1 ) + fObj.pre); + + _log( 'Generating '.useful + fi.fmt.title.toUpperCase().useful.bold + ' resume: '.useful + + path.relative(process.cwd(), f ).useful.bold ); + + var theFormat = _fmts.filter( + function( fmt ) { return fmt.name === fi.fmt.pre; })[0]; + MKDIRP.sync( path.dirname(fOut) ); // Ensure dest folder exists; theFormat.gen.generate( rez, fOut, _opts ); } catch( ex ) { @@ -109,6 +119,128 @@ module.exports = function () { throw ex; } + /** + Validate 1 to N resumes in either FRESH or JSON Resume format. + */ + function validate( src, unused, opts, logger ) { + _log = logger || console.log; + if( !src || !src.length ) { throw { fluenterror: 6 }; } + var isValid = true; + + var validator = require('is-my-json-valid'); + var schemas = { + fresh: require('FRESCA'), + jars: require('./core/resume.json') + }; + + // Load input resumes... + var sheets = loadSourceResumes(src, function( res ) { + try { + return { + file: res, + raw: FS.readFileSync( res, 'utf8' ) + }; + } + catch( ex ) { + throw ex; + } + }); + + sheets.forEach( function( rep ) { + + try { + var rez = JSON.parse( rep.raw ); + } + catch( ex ) { + _log('Validating '.info + rep.file.infoBold + ' against FRESH/JRS schema: '.info + 'ERROR!'.error.bold); + + if (ex instanceof SyntaxError) { + // Invalid JSON + _log( '--> '.bold.red + rep.file.toUpperCase().red + ' contains invalid JSON. Unable to validate.'.red ); + _log( (' INTERNAL: ' + ex).red ); + } + else { + + _log(('ERROR: ' + ex.toString()).red.bold); + } + return; + } + + var isValid = false; + var style = 'useful'; + var errors = []; + + try { + + + + var fmt = rez.meta && rez.meta.format === 'FRESH@0.1.0' ? 'fresh':'jars'; + var validate = validator( schemas[ fmt ], { // Note [1] + formats: { date: /^\d{4}(?:-(?:0[0-9]{1}|1[0-2]{1})(?:-[0-9]{2})?)?$/ } + }); + + isValid = validate( rez ); + if( !isValid ) { + style = 'warn'; + errors = validate.errors; + } + + } + catch(ex) { + + } + + _log( 'Validating '.info + rep.file.infoBold + ' against '.info + + fmt.replace('jars','JSON Resume').toUpperCase().infoBold + ' schema: '.info + (isValid ? 'VALID!' : 'INVALID')[style].bold ); + + errors.forEach(function(err,idx){ + _log( '--> '.bold.yellow + ( err.field.replace('data.','resume.').toUpperCase() + + ' ' + err.message).yellow ); + }); + + + + }); + } + + /** + Convert between FRESH and JRS formats. + */ + function convert( src, dst, opts, logger ) { + _log = logger || console.log; + if( !src || !src.length ) { throw { fluenterror: 6 }; } + if( !dst || !dst.length ) { + if( src.length === 1 ) { throw { fluenterror: 5 }; } + else if( src.length === 2 ) { dst = [ src[1] ]; src = [ src[0] ]; } + else { throw { fluenterror: 5 }; } + } + if( src && dst && src.length && dst.length && src.length !== dst.length ) { + throw { fluenterror: 7 }; + } + var sheets = loadSourceResumes( src ); + sheets.forEach(function(sheet, idx){ + var sourceFormat = sheet.imp.orgFormat === 'JRS' ? 'JRS' : 'FRESH'; + var targetFormat = sourceFormat === 'JRS' ? 'FRESH' : 'JRS'; + _log( 'Converting '.useful + sheet.imp.fileName.useful.bold + (' (' + sourceFormat + ') to ').useful + dst[0].useful.bold + + (' (' + targetFormat + ').').useful ); + sheet.saveAs( dst[idx], targetFormat ); + }); + } + + /** + Display help documentation. + */ + function help() { + console.log( FS.readFileSync( PATH.join(__dirname, 'use.txt'), 'utf8' ).useful.bold ); + } + + function loadSourceResumes( src, fn ) { + return src.map( function( res ) { + _log( 'Reading '.info + 'SOURCE'.infoBold + ' resume: '.info + res.cyan.bold ); + return (fn && fn(res)) || (new FLUENT.FRESHResume()).open( res ); + }); + } + /** Supported resume formats. */ @@ -139,10 +271,18 @@ module.exports = function () { Internal module interface. Used by FCV Desktop and HMR. */ return { - generate: gen, + verbs: { + build: generate, + validate: validate, + convert: convert, + help: help + }, lib: require('./fluentlib'), options: _opts, formats: _fmts }; }(); + +// [1]: JSON.parse throws SyntaxError on invalid JSON. See: +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse diff --git a/src/fluentlib.js b/src/fluentlib.js index ddb4fd2..26e23b7 100644 --- a/src/fluentlib.js +++ b/src/fluentlib.js @@ -1,10 +1,12 @@ /** -Core resume generation module for FluentCV. -@license Copyright (c) 2015 by James M. Devlin. All rights reserved. +External API surface for FluentCV:CLI. +@license MIT. Copyright (c) 2015 James M. Devlin / FluentDesk */ module.exports = { - Sheet: require('./core/sheet'), + Sheet: require('./core/fresh-resume'), + FRESHResume: require('./core/fresh-resume'), + JRSResume: require('./core/jrs-resume'), Theme: require('./core/theme'), FluentDate: require('./core/fluent-date'), HtmlGenerator: require('./gen/html-generator'), diff --git a/src/gen/base-generator.js b/src/gen/base-generator.js index 0a239f1..86bc452 100644 --- a/src/gen/base-generator.js +++ b/src/gen/base-generator.js @@ -29,7 +29,9 @@ Base resume generator for FluentCV. success: 0, themeNotFound: 1, copyCss: 2, - resumeNotFound: 3 + resumeNotFound: 3, + missingCommand: 4, + invalidCommand: 5 }, /** diff --git a/src/gen/json-generator.js b/src/gen/json-generator.js index 218d45b..2099f41 100644 --- a/src/gen/json-generator.js +++ b/src/gen/json-generator.js @@ -19,9 +19,9 @@ var JsonGenerator = module.exports = BaseGenerator.extend({ invoke: function( rez ) { // TODO: merge with FCVD function replacer( key,value ) { // Exclude these keys from stringification - return _.some(['meta', 'warnings', 'computed', 'filt', 'ctrl', 'index', + return _.some(['imp', 'warnings', 'computed', 'filt', 'ctrl', 'index', 'safeStartDate', 'safeEndDate', 'safeDate', 'safeReleaseDate', 'result', - 'isModified', 'htmlPreview'], + 'isModified', 'htmlPreview', 'safe' ], function( val ) { return key.trim() === val; } ) ? undefined : value; } diff --git a/src/gen/template-generator.js b/src/gen/template-generator.js index 2e5e00f..d3b053a 100644 --- a/src/gen/template-generator.js +++ b/src/gen/template-generator.js @@ -31,7 +31,9 @@ var _defaultOpts = { xml: function( txt ) { return XML(txt); }, md: function( txt ) { return MD(txt); }, mdin: function( txt ) { return MD(txt).replace(/^\s*\|\<\/p\>\s*$/gi, ''); }, - lower: function( txt ) { return txt.toLowerCase(); } + lower: function( txt ) { return txt.toLowerCase(); }, + link: function( name, url ) { return url ? + '' + name + '' : name } }, prettify: { // ← See https://github.com/beautify-web/js-beautify#options indent_size: 2, @@ -125,7 +127,6 @@ var TemplateGenerator = module.exports = BaseGenerator.extend({ // Strip {# comments #} jst = jst.replace( _.templateSettings.comment, ''); - json.display_progress_bar = true; // Compile and run the template. TODO: avoid unnecessary recompiles. jst = _.template(jst)({ r: json, filt: this.opts.filters, cssInfo: cssInfo, headFragment: this.opts.headFragment || '' }); diff --git a/src/index.js b/src/index.js index ab98bf0..bb4b5f9 100644 --- a/src/index.js +++ b/src/index.js @@ -1,14 +1,19 @@ #! /usr/bin/env node /** -Command-line interface (CLI) for FluentCV via Node.js. -@license Copyright (c) 2015 | James M. Devlin +Command-line interface (CLI) for FluentCV:CLI. +@license MIT. Copyright (c) 2015 James M. Devlin / FluentDesk. */ var ARGS = require( 'minimist' ) , FCMD = require( './fluentcmd') , PKG = require('../package.json') - , opts = { }; + , COLORS = require('colors') + , FS = require('fs') + , PATH = require('path') + , opts = { } + , title = ('*** FluentCV v' + PKG.version + ' ***').bold.white + , _ = require('underscore'); @@ -23,20 +28,53 @@ catch( ex ) { function main() { - // Setup. - var title = '*** FluentCV v' + PKG.version + ' ***'; - if( process.argv.length <= 2 ) { logMsg(title); throw { fluenterror: 3 }; } - var args = ARGS( process.argv.slice(2) ); - opts = getOpts( args ); + // Colorize + COLORS.setTheme({ + title: ['white','bold'], + info: process.platform === 'win32' ? 'gray' : ['white','dim'], + infoBold: ['white','dim'], + warn: 'yellow', + error: 'red', + guide: 'yellow', + status: 'gray',//['white','dim'], + useful: 'green', + }); + + // Setup + if( process.argv.length <= 2 ) { throw { fluenterror: 4 }; } + var a = ARGS( process.argv.slice(2) ); + opts = getOpts( a ); logMsg( title ); - // Convert arguments to source files, target files, options - var src = args._ || []; - var dst = (args.o && ((typeof args.o === 'string' && [ args.o ]) || args.o)) || []; - dst = (dst === true) ? [] : dst; // Handle -o with missing output file - // Generate! - FCMD.generate( src, dst, opts, logMsg ); + // Get the action to be performed + var params = a._.map( function(p){ return p.toLowerCase().trim(); }); + var verb = params[0]; + if( !FCMD.verbs[ verb ] ) { + logMsg('Invalid command: "'.warn + verb.warn.bold + '"'.warn); + return; + } + + // Get source and dest params + var splitAt = _.indexOf( params, 'to' ); + if( splitAt === a._.length - 1 ) { + // 'TO' cannot be the last argument + logMsg('Please '.warn + 'specify an output file'.warnBold + + ' for this operation or '.warn + 'omit the TO keyword'.warnBold + '.'.warn ); + return; + } + + var src = a._.slice(1, splitAt === -1 ? undefined : splitAt ); + var dst = splitAt === -1 ? [] : a._.slice( splitAt + 1 ); + + // Preload our params array + //var dst = (a.o && ((typeof a.o === 'string' && [ a.o ]) || a.o)) || []; + //dst = (dst === true) ? [] : dst; // Handle -o with missing output file + var parms = [ src, dst, opts, logMsg ]; + + // Invoke the action + FCMD.verbs[ verb ].apply( null, parms ); + } function logMsg( msg ) { @@ -55,13 +93,27 @@ function getOpts( args ) { function handleError( ex ) { var msg = '', exitCode; + + + if( ex.fluenterror ){ switch( ex.fluenterror ) { // TODO: Remove magic numbers case 1: msg = "The specified theme couldn't be found: " + ex.data; break; case 2: msg = "Couldn't copy CSS file to destination folder"; break; - case 3: msg = "Please specify a valid JSON resume file."; break; + case 3: msg = 'Please '.guide + 'specify a valid input resume'.guide.bold + ' in FRESH or JSON Resume format.'.guide; break; + case 4: msg = title + "\nPlease ".guide + "specify a command".guide.bold + " (".guide + + Object.keys( FCMD.verbs ).map( function(v, idx, ar) { + return (idx === ar.length - 1 ? 'or '.guide : '') + + v.toUpperCase().guide; + }).join(', '.guide) + ") to get started.\n\n".guide + FS.readFileSync( PATH.join(__dirname, 'use.txt'), 'utf8' ).info.bold; + break; + //case 4: msg = title + '\n' + ; break; + case 5: msg = 'Please '.guide + 'specify the output resume file'.guide.bold + ' that should be created in the new format.'.guide; break; + case 6: msg = 'Please '.guide + 'specify a valid input resume'.guide.bold + ' in either FRESH or JSON Resume format.'.guide; break; + case 7: msg = 'Please '.guide + 'specify an output file name'.guide.bold + ' for every input file you wish to convert.'.guide; break; }; exitCode = ex.fluenterror; + } else { msg = ex.toString(); @@ -70,7 +122,11 @@ function handleError( ex ) { var idx = msg.indexOf('Error: '); var trimmed = idx === -1 ? msg : msg.substring( idx + 7 ); - console.log( 'ERROR: ' + trimmed.toString() ); + if( !ex.fluenterror || ex.fluenterror < 3 ) + console.log( ('ERROR: ' + trimmed.toString()).red.bold ); + else + console.log( trimmed.toString() ); + process.exit( exitCode ); } diff --git a/src/use.txt b/src/use.txt new file mode 100644 index 0000000..1072347 --- /dev/null +++ b/src/use.txt @@ -0,0 +1,22 @@ +Usage: + + fluentcv [TO ] [-t ] + + should be BUILD, CONVERT, VALIDATE, or HELP. should +be the path to one or more FRESH or JSON Resume format resumes. +should be the name of the destination resume to be created, if any. The + parameter should be the name of a predefined theme (for example: +COMPACT, MINIMIST, MODERN, or HELLO-WORLD) or the relative path to a +custom theme. + + fluentcv BUILD resume.json TO out/resume.all + fluentcv CONVERT resume.json TO resume-jrs.json + fluentcv VALIDATE resume.json + +Both SOURCES and TARGETS can accept multiple files: + + fluentCV BUILD r1.json r2.json TO out/resume.all out2/resume.html + fluentCV VALIDATE resume.json resume2.json resume3.json + +See https://github.com/fluentdesk/fluentCV/blob/master/README.md +for more information. diff --git a/tests/jrs-exemplar/richard-hendriks.json b/tests/jrs-exemplar/richard-hendriks.json new file mode 100644 index 0000000..17fddad --- /dev/null +++ b/tests/jrs-exemplar/richard-hendriks.json @@ -0,0 +1,130 @@ +{ + "basics": { + "name": "Richard Hendriks", + "label": "Programmer", + "picture": "", + "email": "richard.hendriks@gmail.com", + "phone": "(912) 555-4321", + "website": "http://richardhendricks.com", + "summary": "Richard hails from Tulsa. He has earned degrees from the University of Oklahoma and Stanford. (Go Sooners and Cardinals!) Before starting Pied Piper, he worked for Hooli as a part time software developer. While his work focuses on applied information theory, mostly optimizing lossless compression schema of both the length-limited and adaptive variants, his non-work interests range widely, everything from quantum computing to chaos theory. He could tell you about it, but THAT would NOT be a “length-limited” conversation!", + "location": { + "address": "2712 Broadway St", + "postalCode": "CA 94115", + "city": "San Francisco", + "countryCode": "US", + "region": "California" + }, + "profiles": [ + { + "network": "Twitter", + "username": "neutralthoughts", + "url": "" + }, + { + "network": "SoundCloud", + "username": "dandymusicnl", + "url": "https://soundcloud.com/dandymusicnl" + } + ] + }, + "work": [ + { + "company": "Pied Piper", + "position": "CEO/President", + "website": "http://piedpiper.com", + "startDate": "2013-12-01", + "endDate": "2014-12-01", + "summary": "Pied Piper is a multi-platform technology based on a proprietary universal compression algorithm that has consistently fielded high Weisman Scores™ that are not merely competitive, but approach the theoretical limit of lossless compression.", + "highlights": [ + "Build an algorithm for artist to detect if their music was violating copy right infringement laws", + "Successfully won Techcrunch Disrupt", + "Optimized an algorithm that holds the current world record for Weisman Scores" + ] + } + ], + "volunteer": [ + { + "organization": "CoderDojo", + "position": "Teacher", + "website": "http://coderdojo.com/", + "startDate": "2012-01-01", + "endDate": "2013-01-01", + "summary": "Global movement of free coding clubs for young people.", + "highlights": [ + "Awarded 'Teacher of the Month'" + ] + } + ], + "education": [ + { + "institution": "University of Oklahoma", + "area": "Information Technology", + "studyType": "Bachelor", + "startDate": "2011-06-01", + "endDate": "2014-01-01", + "gpa": "4.0", + "courses": [ + "DB1101 - Basic SQL", + "CS2011 - Java Introduction" + ] + } + ], + "awards": [ + { + "title": "Digital Compression Pioneer Award", + "date": "2014-11-01", + "awarder": "Techcrunch", + "summary": "There is no spoon." + } + ], + "publications": [ + { + "name": "Video compression for 3d media", + "publisher": "Hooli", + "releaseDate": "2014-10-01", + "website": "http://en.wikipedia.org/wiki/Silicon_Valley_(TV_series)", + "summary": "Innovative middle-out compression algorithm that changes the way we store data." + } + ], + "skills": [ + { + "name": "Web Development", + "level": "Master", + "keywords": [ + "HTML", + "CSS", + "Javascript" + ] + }, + { + "name": "Compression", + "level": "Master", + "keywords": [ + "Mpeg", + "MP4", + "GIF" + ] + } + ], + "languages": [ + { + "language": "English", + "fluency": "Native speaker" + } + ], + "interests": [ + { + "name": "Wildlife", + "keywords": [ + "Ferrets", + "Unicorns" + ] + } + ], + "references": [ + { + "name": "Erlich Bachman", + "reference": "It is my pleasure to recommend Richard, his performance working as a consultant for Main St. Company proved that he will be a valuable addition to any company." + } + ] +} diff --git a/tests/test-converter.js b/tests/test-converter.js new file mode 100644 index 0000000..ac778e2 --- /dev/null +++ b/tests/test-converter.js @@ -0,0 +1,36 @@ + +var chai = require('chai') + , expect = chai.expect + , should = chai.should() + , path = require('path') + , _ = require('underscore') + , FRESHResume = require('../src/core/fresh-resume') + , CONVERTER = require('../src/core/convert') + , FS = require('fs') + , _ = require('underscore'); + +chai.config.includeStack = false; + +describe('FRESH/JRS converter', function () { + + var _sheet; + + it('should round-trip from JRS to FRESH to JRS without modifying or losing data', function () { + + var fileA = path.join( __dirname, 'jrs-exemplar/richard-hendriks.json' ); + var fileB = path.join( __dirname, 'sandbox/richard-hendriks.json' ); + + _sheet = new FRESHResume().open( fileA ); + _sheet.saveAs( fileB, 'JRS' ); + + var rawA = FS.readFileSync( fileA, 'utf8' ); + var rawB = FS.readFileSync( fileB, 'utf8' ); + + var objA = JSON.parse( rawA ); + var objB = JSON.parse( rawB ); + + _.isEqual(objA, objB).should.equal(true); + + }); + +}); diff --git a/tests/test-fresh-sheet.js b/tests/test-fresh-sheet.js new file mode 100644 index 0000000..d7aa2f5 --- /dev/null +++ b/tests/test-fresh-sheet.js @@ -0,0 +1,73 @@ + +var chai = require('chai') + , expect = chai.expect + , should = chai.should() + , path = require('path') + , _ = require('underscore') + , FRESHResume = require('../src/core/fresh-resume') + , validator = require('is-my-json-valid'); + +chai.config.includeStack = false; + +describe('jane-doe.json (FRESH)', function () { + + var _sheet; + + it('should open without throwing an exception', function () { + function tryOpen() { + _sheet = new FRESHResume().open( + 'node_modules/FRESCA/exemplar/jane-doe.json' ); + } + tryOpen.should.not.Throw(); + }); + + it('should have one or more of each section', function() { + expect( + //(_sheet.basics) && + _sheet.name && _sheet.info && _sheet.location && _sheet.contact && + (_sheet.employment.history && _sheet.employment.history.length > 0) && + (_sheet.skills && _sheet.skills.list.length > 0) && + (_sheet.education.history && _sheet.education.history.length > 0) && + (_sheet.service.history && _sheet.service.history.length > 0) && + (_sheet.writing && _sheet.writing.length > 0) && + (_sheet.recognition && _sheet.recognition.length > 0) && + (_sheet.samples && _sheet.samples.length > 0) && + (_sheet.references && _sheet.references.length > 0) && + (_sheet.interests && _sheet.interests.length > 0) + ).to.equal( true ); + }); + + it('should have a work duration of 7 years', function() { + _sheet.computed.numYears.should.equal( 7 ); + }); + + it('should save without throwing an exception', function(){ + function trySave() { + _sheet.save( 'tests/sandbox/jane-doe.json' ); + } + trySave.should.not.Throw(); + }); + + it('should not be modified after saving', function() { + var savedSheet = new FRESHResume().open('tests/sandbox/jane-doe.json'); + _sheet.stringify().should.equal( savedSheet.stringify() ) + }); + + it('should validate against the FRESH resume schema', function() { + var result = _sheet.isValid(); + // var schemaJson = require('FRESCA'); + // var validate = validator( schemaJson, { verbose: true } ); + // var result = validate( JSON.parse( _sheet.imp.raw ) ); + result || console.log("\n\nOops, resume didn't validate. " + + "Validation errors:\n\n", _sheet.imp.validationErrors, "\n\n"); + result.should.equal( true ); + }); + + +}); + +// describe('subtract', function () { +// it('should return -1 when passed the params (1, 2)', function () { +// expect(math.subtract(1, 2)).to.equal(-1); +// }); +// }); diff --git a/tests/test-sheet.js b/tests/test-jrs-sheet.js similarity index 85% rename from tests/test-sheet.js rename to tests/test-jrs-sheet.js index 454a933..3afcba8 100644 --- a/tests/test-sheet.js +++ b/tests/test-jrs-sheet.js @@ -4,18 +4,18 @@ var chai = require('chai') , should = chai.should() , path = require('path') , _ = require('underscore') - , Sheet = require('../src/core/sheet') + , JRSResume = require('../src/core/jrs-resume') , validator = require('is-my-json-valid'); chai.config.includeStack = false; -describe('fullstack.json', function () { +describe('fullstack.json (JRS)', function () { var _sheet; it('should open without throwing an exception', function () { function tryOpen() { - _sheet = new Sheet().open( 'node_modules/resample/fullstack/in/resume.json' ); + _sheet = new JRSResume().open( 'node_modules/resample/fullstack/in/resume.json' ); } tryOpen.should.not.Throw(); }); @@ -44,14 +44,14 @@ describe('fullstack.json', function () { }); it('should not be modified after saving', function() { - var savedSheet = new Sheet().open( 'tests/sandbox/fullstack.json' ); + var savedSheet = new JRSResume().open( 'tests/sandbox/fullstack.json' ); _sheet.stringify().should.equal( savedSheet.stringify() ) }); it('should validate against the JSON Resume schema', function() { var schemaJson = require('../src/core/resume.json'); var validate = validator( schemaJson, { verbose: true } ); - var result = validate( JSON.parse( _sheet.meta.raw ) ); + var result = validate( JSON.parse( _sheet.imp.raw ) ); result || console.log("\n\nOops, resume didn't validate. " + "Validation errors:\n\n", validate.errors, "\n\n"); result.should.equal( true );