├── .gitignore ├── .gitmodules ├── README.md ├── command ├── command.go └── command_test.go ├── data ├── data.go └── data_test.go ├── sample.json └── testutils └── testutils.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | ResumeFodder.iml 3 | ResumeFodder.exe 4 | resume.doc 5 | resume.docx 6 | resume.pdf 7 | sample.doc 8 | sample.docx 9 | sample.pdf 10 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "templates"] 2 | path = templates 3 | url = https://gitlab.com/steve-perkins/ResumeFodder-templates.git 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ResumeFodder 2 | ============ 3 | 4 | > NOTE: Primary development has moved over to GitLab: https://gitlab.com/steve-perkins/ResumeFodder. 5 | > If you're reading this on GitHub, then note that this repo is a mirror which can sometimes be slightly 6 | > out of date. 7 | 8 | ResumeFodder is an application for generating Microsoft Word resumes from 9 | [JSON Resume](https://github.com/jsonresume/resume-schema) data files. 10 | 11 | https://resumefodder.com 12 | 13 | This repository contains the core functionality for parsing JSON Resume data and processing templates. 14 | There are three other related git repositories: 15 | 16 | * [ResumeFodder-cli](https://gitlab.com/steve-perkins/ResumeFodder-cli) - A command-line front end that 17 | compiles to a standalone executable to run on your local machine. 18 | 19 | * [ResumeFodder-appengine](https://gitlab.com/steve-perkins/ResumeFodder-appengine) - A web application 20 | front end, for using ResumeFodder online without having to install any software. Currently running 21 | live on Google App Engine at: https://resumefodder.com. 22 | 23 | * [ResumeFodder-templates](https://gitlab.com/steve-perkins/ResumeFodder-templates) - All of the Go 24 | templates available to ResumeFodder. This repository is imported into all of the others a git submodule. 25 | 26 | Copyright 2016 [Steve Perkins](http://steveperkins.com) 27 | 28 | [MIT License](https://opensource.org/licenses/MIT) 29 | -------------------------------------------------------------------------------- /command/command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "gitlab.com/steve-perkins/ResumeFodder/data" 8 | "io/ioutil" 9 | "os" 10 | "path" 11 | "path/filepath" 12 | "strings" 13 | "text/template" 14 | "time" 15 | ) 16 | 17 | // InitResume writes a new, empty resume data file to the destination specified by the filename argument. That 18 | // filename must have an extension of ".xml" or ".json", and XML or JSON format will be used accordingly. 19 | func InitResumeFile(filename string) error { 20 | if strings.ToLower(path.Ext(filename)) == ".xml" { 21 | return data.ToXmlFile(data.NewResumeData(), filename) 22 | } else { 23 | return data.ToJsonFile(data.NewResumeData(), filename) 24 | } 25 | } 26 | 27 | // InitResumeJson returns the JSON text of a new, empty resume data file. 28 | func InitResumeJson() (string, error) { 29 | return data.ToJsonString(data.NewResumeData()) 30 | } 31 | 32 | // InitResume returns the XML text of a new, empty resume data file. 33 | func InitResumeXml() (string, error) { 34 | return data.ToXmlString(data.NewResumeData()) 35 | } 36 | 37 | // ConvertResume reads a resume data file in XML or JSON format, and writes that data to another destination file 38 | // in XML or JSON format. 39 | func ConvertResumeFile(inputFilename, outputFilename string) error { 40 | var resume data.ResumeData 41 | var err error 42 | if strings.ToLower(path.Ext(inputFilename)) == ".xml" { 43 | resume, err = data.FromXmlFile(inputFilename) 44 | } else { 45 | resume, err = data.FromJsonFile(inputFilename) 46 | } 47 | if err != nil { 48 | return err 49 | } 50 | 51 | if strings.ToLower(path.Ext(outputFilename)) == ".xml" { 52 | return data.ToXmlFile(resume, outputFilename) 53 | } else { 54 | return data.ToJsonFile(resume, outputFilename) 55 | } 56 | } 57 | 58 | // ExportResumeFile applies a Word 2003 XML template to a resume data file, resulting in a Word document. This function 59 | // accepts path and filenames of the resume data file and template file on disk, and the path and filename to which 60 | // the resume output will be written on disk. 61 | // 62 | // See: 63 | // https://en.wikipedia.org/wiki/Microsoft_Office_XML_formats 64 | // https://www.microsoft.com/en-us/download/details.aspx?id=101 65 | func ExportResumeFile(inputFilename, outputFilename, templateFilename string) error { 66 | 67 | // For some reason, I'm getting blank final results when loading templates via "ParseFiles()"... but it DOES work 68 | // when I first read the template contents into a string and load that via "Parse()". 69 | templateBytes, err := ioutil.ReadFile(templateFilename) 70 | if err != nil { 71 | // Look for template files at the raw path provided. If not found, then try looking for then beneath 72 | // the "templates" subdirectory 73 | templatePath := filepath.Join("templates", templateFilename) 74 | templateBytes, err = ioutil.ReadFile(templatePath) 75 | if err != nil { 76 | message := fmt.Sprintf("Could not find %s or %s", templateFilename, templatePath) 77 | return errors.New(message) 78 | } 79 | } 80 | templateString := string(templateBytes) 81 | 82 | // Load the resume data 83 | var resumeData data.ResumeData 84 | extension := strings.ToLower(path.Ext(inputFilename)) 85 | if extension == ".xml" { 86 | resumeData, err = data.FromXmlFile(inputFilename) 87 | } else if extension == ".json" { 88 | resumeData, err = data.FromJsonFile(inputFilename) 89 | } else { 90 | err = errors.New("Resume filename must end with \".xml\" or \".json\".") 91 | } 92 | if err != nil { 93 | return nil 94 | } 95 | 96 | // Execute the template engine 97 | buffer, err := ExportResume(resumeData, templateString) 98 | if err != nil { 99 | return nil 100 | } 101 | 102 | // Open the output file and write out the resume contents 103 | outfile, err := os.OpenFile(outputFilename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) 104 | if err != nil { 105 | return err 106 | } 107 | defer outfile.Close() 108 | _, err = buffer.WriteTo(outfile) 109 | return err 110 | } 111 | 112 | // ExportResume applies a Word 2003 XML template to a resume data file, resulting in a Word document. This function 113 | // accepts the raw resume data structure and the raw template contents directly, returning the generated resume 114 | // contents in a Writer that can be written to disk or HTTP download. 115 | func ExportResume(resumeData data.ResumeData, templateContent string) (*bytes.Buffer, error) { 116 | // Initialize the template engine 117 | funcMap := template.FuncMap{ 118 | "plus1": func(x int) int { 119 | return x + 1 120 | }, 121 | "toUpper": func(s string) string { 122 | return strings.ToUpper(s) 123 | }, 124 | "YYYY": func(s string) string { 125 | const inputFormat = "2006-01-02" 126 | dateValue, err := time.Parse(inputFormat, s) 127 | if err != nil { 128 | return s 129 | } 130 | const outputFormat = "2006" 131 | return dateValue.Format(outputFormat) 132 | }, 133 | "MYY": func(s string) string { 134 | const inputFormat = "2006-01-02" 135 | dateValue, err := time.Parse(inputFormat, s) 136 | if err != nil { 137 | return s 138 | } 139 | const outputFormat = "1/06" 140 | return dateValue.Format(outputFormat) 141 | }, 142 | "MYYYY": func(s string) string { 143 | const inputFormat = "2006-01-02" 144 | dateValue, err := time.Parse(inputFormat, s) 145 | if err != nil { 146 | return s 147 | } 148 | const outputFormat = "1/2006" 149 | return dateValue.Format(outputFormat) 150 | }, 151 | "MMMMYYYY": func(s string) string { 152 | const inputFormat = "2006-01-02" 153 | dateValue, err := time.Parse(inputFormat, s) 154 | if err != nil { 155 | return s 156 | } 157 | const outputFormat = "January 2006" 158 | return dateValue.Format(outputFormat) 159 | }, 160 | // TODO: I'd love to come up with a reflection-based solution for splitting a slice of any type. God, I wish Go just had generics... 161 | "firstHalfSkills": func(list []data.Skill) []data.Skill { 162 | if list == nil || len(list) == 0 { 163 | return nil 164 | } else if len(list) == 1 { 165 | return list 166 | } else { 167 | end := len(list)/2 + len(list)%2 - 1 168 | return list[:end] 169 | } 170 | }, 171 | "secondHalfSkills": func(list []data.Skill) []data.Skill { 172 | if list == nil || len(list) < 2 { 173 | return nil 174 | } else { 175 | start := len(list)/2 + len(list)%2 176 | return list[start:] 177 | } 178 | }, 179 | } 180 | buffer := bytes.NewBuffer(nil) 181 | resumeTemplate, err := template.New("resume").Funcs(funcMap).Parse(templateContent) 182 | if err != nil { 183 | return buffer, err 184 | } 185 | err = resumeTemplate.Execute(buffer, resumeData) 186 | return buffer, err 187 | } 188 | -------------------------------------------------------------------------------- /command/command_test.go: -------------------------------------------------------------------------------- 1 | package command_test 2 | 3 | import ( 4 | "gitlab.com/steve-perkins/ResumeFodder/command" 5 | "gitlab.com/steve-perkins/ResumeFodder/data" 6 | "gitlab.com/steve-perkins/ResumeFodder/testutils" 7 | "os" 8 | "path/filepath" 9 | "reflect" 10 | "testing" 11 | ) 12 | 13 | func TestInitResumeFile(t *testing.T) { 14 | // Delete any pre-existing test file now, and then also clean up afterwards 15 | filename := filepath.Join(os.TempDir(), "testresume.xml") 16 | testutils.DeleteFileIfExists(t, filename) 17 | defer testutils.DeleteFileIfExists(t, filename) 18 | 19 | err := command.InitResumeFile(filename) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | inMemory := data.NewResumeData() 24 | fromFile, err := data.FromXmlFile(filename) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | if !reflect.DeepEqual(inMemory, fromFile) { 29 | t.Fatal("Resume data after XML conversion doesn't match the original") 30 | } 31 | } 32 | 33 | func TestInitResumeJson(t *testing.T) { 34 | json, err := command.InitResumeJson() 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | inMemory := data.NewResumeData() 39 | fromString, err := data.FromJsonString(json) 40 | if !reflect.DeepEqual(inMemory, fromString) { 41 | t.Fatal("Resume data after conversion doesn't match the original") 42 | } 43 | } 44 | 45 | func TestInitResumeXml(t *testing.T) { 46 | xml, err := command.InitResumeXml() 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | inMemory := data.NewResumeData() 51 | fromString, err := data.FromXmlString(xml) 52 | if !reflect.DeepEqual(inMemory, fromString) { 53 | t.Fatal("Resume data after conversion doesn't match the original") 54 | } 55 | } 56 | 57 | func TestConvertResumeFile(t *testing.T) { 58 | xmlFilename := filepath.Join(os.TempDir(), "testresume.xml") 59 | testutils.DeleteFileIfExists(t, xmlFilename) 60 | defer testutils.DeleteFileIfExists(t, xmlFilename) 61 | 62 | jsonFilename := filepath.Join(os.TempDir(), "testresume.json") 63 | testutils.DeleteFileIfExists(t, jsonFilename) 64 | defer testutils.DeleteFileIfExists(t, jsonFilename) 65 | 66 | err := command.InitResumeFile(xmlFilename) 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | err = command.ConvertResumeFile(xmlFilename, jsonFilename) 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | 75 | inMemory := data.NewResumeData() 76 | fromFile, err := data.FromJsonFile(jsonFilename) 77 | if err != nil { 78 | t.Fatal(err) 79 | } 80 | if !reflect.DeepEqual(inMemory, fromFile) { 81 | t.Fatal("Resume data after XML-to-JSON conversion doesn't match the original") 82 | } 83 | } 84 | 85 | // See also "TestExportResume_TemplateDefaultPath()", in the base "ResumeFodder" project's "main_test.go" test file. 86 | func TestExportResumeFile_TemplateRelativePath(t *testing.T) { 87 | xmlFilename := filepath.Join(os.TempDir(), "testresume.xml") 88 | testutils.DeleteFileIfExists(t, xmlFilename) 89 | defer testutils.DeleteFileIfExists(t, xmlFilename) 90 | 91 | resumeData := testutils.GenerateTestResumeData() 92 | err := data.ToXmlFile(resumeData, xmlFilename) 93 | if err != nil { 94 | t.Fatal(err) 95 | } 96 | 97 | outputFilename := filepath.Join(os.TempDir(), "resume.doc") 98 | templateFilename := filepath.Join("..", "templates", "standard.xml") 99 | err = command.ExportResumeFile(xmlFilename, outputFilename, templateFilename) 100 | if err != nil { 101 | t.Fatal(err) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /data/data.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "io/ioutil" 7 | ) 8 | 9 | const SCHEMA_VERSION = 1 10 | 11 | // ResumeData is the outermost container for resume data. 12 | type ResumeData struct { 13 | // XMLName provides a name for the top-level element, when working with resume data files in XML format. This 14 | // field is ignored when working with files in JSON format. 15 | XMLName xml.Name `xml:"resume" json:"-"` 16 | // Version is an identifier for the schema structure. If breaking changes occur in the future, then ResumeFodder 17 | // can use this value to recognize the incompatibility and provide options. 18 | Version int `xml:"version" json:"version"` 19 | Basics Basics `xml:"basics" json:"basics"` 20 | Work []Work `xml:"work" json:"work"` 21 | // AdditionalWork is an extra field, not found within the standard JSON-Resume spec. It is intended to store 22 | // employment history that should be presented differently from that in the main "Work" field. 23 | // 24 | // Specifically, if you have a lengthy work history, then you might store the oldest jobs in this field... so that 25 | // a template can present them in abbreviated format (i.e. no highlights), with perhaps a "Further details 26 | // available upon request"-type note. It could similarly be used for high-school or college jobs that are 27 | // only worth mentioning for entry-level candidates only. 28 | // 29 | // Obviously, the records in this extra field would be ignored if you used your data file with a standard 30 | // JSON-Resume processor. Otherwise, migration would require you to move any "AdditionalWork" records to the 31 | // "Work" field. 32 | AdditionalWork []Work `xml:"additionalWork" json:"additionalWork"` 33 | // WorkLabel is an extra field, not found within the standard JSON-Resume spec. It is intended to tell templates 34 | // how to present the "Work" and "AdditionalWork" sections, when both are used (e.g. "Recent Experience" 35 | // versus "Prior Experience"). 36 | WorkLabel string `xml:"workLabel" json:"workLabel"` 37 | // AdditionalWorkLabel is an extra field, not found within the standard JSON-Resume spec. It is intended to tell 38 | // templates how to present the "Work" and "AdditionalWork" sections, when both are used (e.g. "Recent Experience" 39 | // versus "Prior Experience"). 40 | AdditionalWorkLabel string `xml:"additionalWorkLabel" json:"additionalWorkLabel"` 41 | Education []Education `xml:"education" json:"education"` 42 | Publications []Publication `xml:"publications" json:"publications"` 43 | // AdditionalPublications is an extra field, not found within the standard JSON-Resume spec. It is intended to 44 | // store publications that should be presented differently from those in the main "Publications" field. 45 | // 46 | // Specifically, if you collaborated on publications in which you were not an author or co-author (e.g. a technical 47 | // reviewer instead), then you might store those publications here so that a template can present them 48 | // without implying that you were an author. 49 | // 50 | // Obviously, the records in this extra field would be ignored if you used your data file with a standard 51 | // JSON-Resume processor. Otherwise, migration would require you to move any "AdditionalPublications" records to 52 | // the "Publications" field. 53 | AdditionalPublications []Publication `xml:"additionalPublications" json:"additionalPublications"` 54 | // PublicationsLabel is an extra field, not found within the standard JSON-Resume spec. It is intended to tell 55 | // templates how to present the "Publications" and "AdditionalPublications" sections, when both are used 56 | // (e.g. "Publications (Author)" versus "Publications (Technical Reviewer)"). 57 | PublicationsLabel string `xml:"publicationsLabel" json:"publicationsLabel"` 58 | // AdditionalPublicationsLabel is an extra field, not found within the standard JSON-Resume spec. It is intended 59 | // to tell templates how to present the "Publications" and "AdditionalPublications" sections, when both are used 60 | // (e.g. "Publications (Author)" versus "Publications (Technical Reviewer)"). 61 | AdditionalPublicationsLabel string `xml:"additionalPublicationsLabel" json:"additionalPublicationsLabel"` 62 | Skills []Skill `xml:"skills" json:"skills"` 63 | } 64 | 65 | // Basics is a container for top-level resume data. These fields could just as well hang off the parent "ResumeData" 66 | // struct, but this structure mirrors how the JSON-Resume spec arranges them. 67 | type Basics struct { 68 | Name string `xml:"name" json:"name"` 69 | Label string `xml:"label" json:"label"` 70 | Picture string `xml:"picture" json:"picture"` 71 | Email string `xml:"email" json:"email"` 72 | Phone string `xml:"phone" json:"phone"` 73 | Degree string `xml:"degree" json:"degree"` 74 | Website string `xml:"website" json:"website"` 75 | Summary string `xml:"summary" json:"summary"` 76 | // Highlights is an extra field, not found within the standard JSON-Resume spec. It is intended for additional 77 | // top-level information, that a template might present with a bullet-point list or other similar formatting 78 | // next to the top-level "Summary" field. 79 | // 80 | // Obviously, the records in this extra field would be ignored if you used your data file with a standard 81 | // JSON-Resume processor. Once the other JSON-Resume processors gain mature support for HTML and/or Markdown 82 | // line-break formatting within field values, then perhaps you could migrate "Highlights" data to within the 83 | // "Summary" field. 84 | Highlights []string `xml:"highlights" json:"highlights"` 85 | Location Location `xml:"location" json:"location"` 86 | Profiles []SocialProfile `xml:"profiles" json:"profiles"` 87 | } 88 | 89 | type Location struct { 90 | Address string `xml:"address" json:"address"` 91 | PostalCode string `xml:"postalCode" json:"postalCode"` 92 | City string `xml:"city" json:"city"` 93 | CountryCode string `xml:"countryCode" json:"countryCode"` 94 | Region string `xml:"region" json:"region"` 95 | } 96 | 97 | type SocialProfile struct { 98 | Network string `xml:"network" json:"network"` 99 | Username string `xml:"username" json:"username"` 100 | Url string `xml:"url" json:"url"` 101 | } 102 | 103 | type Work struct { 104 | // TODO: Perhaps job listings should have 'City' and 'Region' extension fields, as this is commonly found on resumes 105 | Company string `xml:"company" json:"company"` 106 | Position string `xml:"position" json:"position"` 107 | Website string `xml:"website" json:"website"` 108 | StartDate string `xml:"startDate" json:"startDate"` 109 | EndDate string `xml:"endDate" json:"endDate"` 110 | Summary string `xml:"summary" json:"summary"` 111 | Highlights []string `xml:"highlights" json:"highlights"` 112 | } 113 | 114 | type Education struct { 115 | // TODO: Perhaps education listings should have 'City' and 'Region' extension fields, as this is commonly found on resumes 116 | Institution string `xml:"institution" json:"institution"` 117 | Area string `xml:"area" json:"area"` 118 | StudyType string `xml:"studyType" json:"studyType"` 119 | StartDate string `xml:"startDate" json:"startDate"` 120 | EndDate string `xml:"endDate" json:"endDate"` 121 | GPA string `xml:"gpa" json:"gpa"` 122 | Courses []string `xml:"courses" json:"courses"` 123 | } 124 | 125 | type PublicationGroup struct { 126 | Name string `xml:"name" json:"name"` 127 | Publications []Publication `xml:"publications" json:"publications"` 128 | } 129 | 130 | type Publication struct { 131 | Name string `xml:"name" json:"name"` 132 | Publisher string `xml:"publisher" json:"publisher"` 133 | ReleaseDate string `xml:"releaseDate" json:"releaseDate"` 134 | Website string `xml:"website" json:"website"` 135 | Summary string `xml:"summary" json:"summary"` 136 | // ISBN is an extra field, not found within the standard JSON-Resume spec. Obviously, this value will be 137 | // ignored if you used your data file with another JSON-Resume processor. You could perhaps migrate by 138 | // cramming this info into the "Summary" field. 139 | ISBN string `xml:"isbn" json:"isbn"` 140 | } 141 | 142 | type Skill struct { 143 | Name string `xml:"name" json:"name"` 144 | Level string `xml:"level" json:"level"` 145 | Keywords []string `xml:"keywords" json:"keywords"` 146 | } 147 | 148 | // NewResumeData initializes a ResumeData struct, with ALL nested structs initialized 149 | // to empty state (rather than just omitted). Useful for generating a blank XML or JSON 150 | // file with all fields forced to be present. 151 | // 152 | // Of course, if you simply need to initialize a blank struct without superfluous 153 | // nested fields, then you can always instead simply declare: 154 | // 155 | // data := data.ResumeData{} 156 | // 157 | func NewResumeData() ResumeData { 158 | return ResumeData{ 159 | Version: SCHEMA_VERSION, 160 | Basics: Basics{ 161 | Location: Location{}, 162 | Profiles: []SocialProfile{{}}, 163 | }, 164 | Work: []Work{ 165 | { 166 | Highlights: []string{""}, 167 | }, 168 | }, 169 | AdditionalWork: []Work{ 170 | { 171 | Highlights: []string{""}, 172 | }, 173 | }, 174 | Education: []Education{ 175 | { 176 | Courses: []string{""}, 177 | }, 178 | }, 179 | Publications: []Publication{{}}, 180 | AdditionalPublications: []Publication{{}}, 181 | Skills: []Skill{ 182 | { 183 | Keywords: []string{""}, 184 | }, 185 | }, 186 | } 187 | } 188 | 189 | // FromXmlString loads a ResumeData struct from a string of XML text. 190 | func FromXmlString(xmlString string) (ResumeData, error) { 191 | return fromXml([]byte(xmlString)) 192 | } 193 | 194 | // FromXmlFile loads a ResumeData struct from an XML file. 195 | func FromXmlFile(xmlFilename string) (ResumeData, error) { 196 | bytes, err := ioutil.ReadFile(xmlFilename) 197 | if err != nil { 198 | return ResumeData{}, err 199 | } 200 | return fromXml(bytes) 201 | } 202 | 203 | // fromXml is a private function that provides the core logic for `FromXmlString` and `FromXmlFile`. 204 | func fromXml(xmlBytes []byte) (ResumeData, error) { 205 | var data ResumeData 206 | err := xml.Unmarshal(xmlBytes, &data) 207 | if err == nil { 208 | // The marshal process in `toXml()` will use field tags to populate the `ResumeData.XMLName` field 209 | // with `resume`. When unmarshalling from XML, we likewise strip this field value back off... to 210 | // better facilitate equality comparison between `ResumeData` structs (e.g. in unit testing). 211 | data.XMLName.Local = "" 212 | } 213 | return data, err 214 | } 215 | 216 | // ToXmlString writes a ResumeData struct to a string of XML text. 217 | func ToXmlString(data ResumeData) (string, error) { 218 | xmlBytes, err := toXml(data) 219 | if err != nil { 220 | return "", err 221 | } 222 | return string(xmlBytes[:]), nil 223 | } 224 | 225 | // ToXmlFile writes a ResumeData struct to an XML file. 226 | func ToXmlFile(data ResumeData, xmlFilename string) error { 227 | xmlBytes, err := toXml(data) 228 | if err != nil { 229 | return err 230 | } 231 | if err := ioutil.WriteFile(xmlFilename, xmlBytes, 0644); err != nil { 232 | return err 233 | } 234 | return nil 235 | } 236 | 237 | // toXml is a private function that provides the core logic for `ToXmlString` and `ToXmlFile`. 238 | func toXml(data ResumeData) ([]byte, error) { 239 | return xml.MarshalIndent(data, "", " ") 240 | } 241 | 242 | // FromJsonString loads a ResumeData struct from a string of JSON text. 243 | func FromJsonString(jsonString string) (ResumeData, error) { 244 | return fromJson([]byte(jsonString)) 245 | } 246 | 247 | // FromJsonFile loads a ResumeData struct from a JSON file. 248 | func FromJsonFile(jsonFilename string) (ResumeData, error) { 249 | bytes, err := ioutil.ReadFile(jsonFilename) 250 | if err != nil { 251 | return ResumeData{}, err 252 | } 253 | return fromJson(bytes) 254 | } 255 | 256 | // fromJson is a private function that provides the core logic for `FromJsonString` and `FromJsonFile`. 257 | func fromJson(jsonBytes []byte) (ResumeData, error) { 258 | var data ResumeData 259 | err := json.Unmarshal(jsonBytes, &data) 260 | return data, err 261 | } 262 | 263 | // ToJsonString writes a ResumeData struct to a string of JSON text. 264 | func ToJsonString(data ResumeData) (string, error) { 265 | jsonBytes, err := toJson(data) 266 | if err != nil { 267 | return "", err 268 | } 269 | return string(jsonBytes[:]), nil 270 | } 271 | 272 | // ToJsonFile writes a ResumeData struct to a JSON file. 273 | func ToJsonFile(data ResumeData, jsonFilename string) error { 274 | jsonBytes, err := toJson(data) 275 | if err != nil { 276 | return err 277 | } 278 | if err := ioutil.WriteFile(jsonFilename, jsonBytes, 0644); err != nil { 279 | return err 280 | } 281 | return nil 282 | } 283 | 284 | // toJson is a private function that provides the core logic for `ToJsonString` and `ToJsonFile`. 285 | func toJson(data ResumeData) ([]byte, error) { 286 | return json.MarshalIndent(data, "", " ") 287 | } 288 | -------------------------------------------------------------------------------- /data/data_test.go: -------------------------------------------------------------------------------- 1 | package data_test 2 | 3 | import ( 4 | "gitlab.com/steve-perkins/ResumeFodder/data" 5 | "gitlab.com/steve-perkins/ResumeFodder/testutils" 6 | "os" 7 | "path/filepath" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestXmlConversion(t *testing.T) { 13 | originalData := testutils.GenerateTestResumeData() 14 | 15 | // Convert the data structure to a string of XML text 16 | xml, err := data.ToXmlString(originalData) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // Parse that XML text into a new resume data structure 22 | fromXmlData, err := data.FromXmlString(xml) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | // Compare the original data structure against this round-trip copy, to see if anything changed. 28 | if !reflect.DeepEqual(originalData, fromXmlData) { 29 | t.Fatal("Resume data after XML conversion doesn't match the original") 30 | } 31 | } 32 | 33 | func TestJsonConversion(t *testing.T) { 34 | originalData := testutils.GenerateTestResumeData() 35 | 36 | json, err := data.ToJsonString(originalData) 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | fromJsonData, err := data.FromJsonString(json) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | if !reflect.DeepEqual(originalData, fromJsonData) { 46 | t.Fatal("Resume data after JSON conversion doesn't match the original") 47 | } 48 | } 49 | 50 | func TestXmlToJsonConversion(t *testing.T) { 51 | originalData := testutils.GenerateTestResumeData() 52 | 53 | xml, err := data.ToXmlString(originalData) 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | fromXmlData, err := data.FromXmlString(xml) 58 | if err != nil { 59 | t.Fatal(err) 60 | } 61 | json, err := data.ToJsonString(fromXmlData) 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | fromJsonData, err := data.FromJsonString(json) 66 | if err != nil { 67 | t.Fatal(err) 68 | } 69 | 70 | if !reflect.DeepEqual(originalData, fromJsonData) { 71 | t.Fatal("Resume data after XML-to-JSON conversion doesn't match the original") 72 | } 73 | } 74 | 75 | func TestJsonToXmlConversion(t *testing.T) { 76 | originalData := testutils.GenerateTestResumeData() 77 | 78 | json, err := data.ToJsonString(originalData) 79 | if err != nil { 80 | t.Fatal(err) 81 | } 82 | fromJsonData, err := data.FromJsonString(json) 83 | if err != nil { 84 | t.Fatal(err) 85 | } 86 | xml, err := data.ToXmlString(fromJsonData) 87 | if err != nil { 88 | t.Fatal(err) 89 | } 90 | fromXmlData, err := data.FromXmlString(xml) 91 | if err != nil { 92 | t.Fatal(err) 93 | } 94 | 95 | if !reflect.DeepEqual(originalData, fromXmlData) { 96 | t.Fatal("Resume data after JSON-to-XML conversion doesn't match the original") 97 | } 98 | } 99 | 100 | func TestXmlFileConversion(t *testing.T) { 101 | // Delete any pre-existing XML test file now, and then also clean up afterwards 102 | xmlFilename := filepath.Join(os.TempDir(), "testresume.xml") 103 | testutils.DeleteFileIfExists(t, xmlFilename) 104 | defer testutils.DeleteFileIfExists(t, xmlFilename) 105 | 106 | // Write a resume data structure to an XML test file in the temp directory 107 | originalData := testutils.GenerateTestResumeData() 108 | err := data.ToXmlFile(originalData, xmlFilename) 109 | if err != nil { 110 | t.Fatal(err) 111 | } 112 | 113 | // Parse that XML file back into a new resume data structure 114 | fromXmlData, err := data.FromXmlFile(xmlFilename) 115 | if err != nil { 116 | t.Fatal(err) 117 | } 118 | 119 | // Compare the original data structure against this round-trip copy, to see if anything changed. 120 | if !reflect.DeepEqual(originalData, fromXmlData) { 121 | t.Fatal("Resume data after XML conversion doesn't match the original") 122 | } 123 | } 124 | 125 | func TestJsonFileConversion(t *testing.T) { 126 | jsonFilename := filepath.Join(os.TempDir(), "testresume.json") 127 | testutils.DeleteFileIfExists(t, jsonFilename) 128 | defer testutils.DeleteFileIfExists(t, jsonFilename) 129 | 130 | originalData := testutils.GenerateTestResumeData() 131 | err := data.ToJsonFile(originalData, jsonFilename) 132 | if err != nil { 133 | t.Fatal(err) 134 | } 135 | fromJsonData, err := data.FromJsonFile(jsonFilename) 136 | if err != nil { 137 | t.Fatal(err) 138 | } 139 | if !reflect.DeepEqual(originalData, fromJsonData) { 140 | t.Fatal("Resume data after JSON conversion doesn't match the original") 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "basics": { 3 | "name": "Peter Gibbons", 4 | "label": "", 5 | "picture": "", 6 | "email": "peter.gibbons@initech.com", 7 | "phone": "555-555-5555", 8 | "degree": "", 9 | "website": "", 10 | "summary": "Just a straight-shooter, with upper management written all over him.", 11 | "highlights": [ 12 | "I once did nothing for an entire day.", 13 | "It was everything I thought it could be." 14 | ], 15 | "location": { 16 | "address": "123 Main Street", 17 | "postalCode": "55555", 18 | "city": "Austin", 19 | "countryCode": "", 20 | "region": "TX" 21 | }, 22 | "profiles": [ 23 | { 24 | "network": "LinkedIn", 25 | "username": "peter.gibbons", 26 | "url": "http://linkedin.com/peter.gibbons" 27 | } 28 | ] 29 | }, 30 | "workLabel": "Professional Experience", 31 | "work": [ 32 | { 33 | "company": "Magazine Clearinghouse", 34 | "position": "Door-to-Door Salesperson", 35 | "website": "", 36 | "startDate": "2002-05-01", 37 | "endDate": "", 38 | "summary": "Magazine Clearinghouse is the nation's leader in door-to-door magazine sales. Provides opportunities to people who used to be addicted to crack, etc.", 39 | "highlights": [ 40 | "Sold up to 30 magazine subscriptions in a single day.", 41 | "Make more money than I ever did working for Initech." 42 | ] 43 | }, 44 | { 45 | "company": "Initech", 46 | "position": "Software Development Manager", 47 | "website": "", 48 | "startDate": "1998-02-01", 49 | "endDate": "2002-05-01", 50 | "summary": "Initech is an innovative financial services firm, and TPS Report processor. However, we also know how to relax and have fun, such as with our Hawaiian Shirt Fridays program.", 51 | "highlights": [ 52 | "Outstanding multitasker, I dealt with seven different bosses simultaneously. Seven, Bob.", 53 | "Promoted to management, I had s many as four people working right underneath me." 54 | ] 55 | }, 56 | { 57 | "company": "Initrode", 58 | "position": "Software Developer", 59 | "website": "", 60 | "startDate": "1996-02-01", 61 | "endDate": "1998-02-01", 62 | "summary": "Ehh... it's work.", 63 | "highlights": [ 64 | "Responsible for identifying Y2K-related issues in application code.", 65 | "Diagnosed 'PC Load Letter' issues. I actually do know what that means." 66 | ] 67 | } 68 | ], 69 | "additionalWorkLabel": "Academic Work Experience", 70 | "additionalWork": [ 71 | { 72 | "company": "Flingers", 73 | "position": "Server", 74 | "website": "", 75 | "startDate": "1993-08-01", 76 | "endDate": "1996-02-01", 77 | "summary": "Paying my way through school with an exciting opportunity in the fast-food service industry.", 78 | "highlights": [ 79 | "Wore 37 pieces of flair.", 80 | "A terrific smile." 81 | ] 82 | } 83 | ], 84 | "education": [ 85 | { 86 | "institution": "University of Austin", 87 | "area": "B.S. Computer Science", 88 | "studyType": "", 89 | "startDate": "1993-09-01", 90 | "endDate": "1997-12-01", 91 | "gpa": "" 92 | } 93 | ], 94 | "publicationsLabel": "Publications", 95 | "publications": [ 96 | { 97 | "name": "Money Laundering for Dummies", 98 | "publisher": "John Wiley \u0026 Sons", 99 | "releaseDate": "1999-06-01", 100 | "website": "", 101 | "summary": "Similar to the plot from \"Superman III\"", 102 | "isbn": "1234567890X" 103 | } 104 | ], 105 | "additionalPublicationsLabel": "Academic Publications", 106 | "additionalPublications": [ 107 | { 108 | "name": "Washington High School Class of 1993 Yearbook", 109 | "publisher": "", 110 | "releaseDate": "1993-06-01", 111 | "website": "", 112 | "summary": "Served as understudy to the assistant editor for my high school yearbook.", 113 | "isbn": "" 114 | } 115 | ], 116 | "skills": [ 117 | { 118 | "name": "Programming", 119 | "level": "Mid-level", 120 | "keywords": [ 121 | "C++", 122 | "Java" 123 | ] 124 | }, 125 | { 126 | "name": "Communication", 127 | "level": "Junior", 128 | "keywords": [ 129 | "Verbal", 130 | "Written" 131 | ] 132 | } 133 | ] 134 | } -------------------------------------------------------------------------------- /testutils/testutils.go: -------------------------------------------------------------------------------- 1 | package testutils 2 | 3 | import ( 4 | "gitlab.com/steve-perkins/ResumeFodder/data" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | // A helper function to generate fake `ResumeData` structs, for use by the various test functions. 10 | func GenerateTestResumeData() data.ResumeData { 11 | data := data.ResumeData{ 12 | Version: data.SCHEMA_VERSION, 13 | Basics: data.Basics{ 14 | Name: "Peter Gibbons", 15 | Email: "peter.gibbons@initech.com", 16 | Phone: "555-555-5555", 17 | Summary: "Just a straight-shooter with upper managment written all over him", 18 | Highlights: []string{ 19 | "Once did nothing for an entire day.", 20 | "It was everything I thought it could be.", 21 | }, 22 | Location: data.Location{ 23 | Address: "123 Main Street", 24 | City: "Austin", 25 | Region: "TX", 26 | PostalCode: "55555", 27 | }, 28 | Profiles: []data.SocialProfile{ 29 | { 30 | Network: "LinkedIn", 31 | Username: "peter.gibbons", 32 | Url: "http://linkedin.com/peter.gibbons", 33 | }, 34 | }, 35 | }, 36 | Work: []data.Work{ 37 | { 38 | Company: "Initech", 39 | Position: "Software Developer", 40 | StartDate: "1998-02-01", 41 | Summary: "Deals with the customers so the engineers don't have to. A people person, damn it!", 42 | Highlights: []string{ 43 | "Identifying Y2K-related issues in application code.", 44 | "As many as four people working right underneath me.", 45 | }, 46 | }, 47 | }, 48 | WorkLabel: "Professional Experience", 49 | AdditionalWork: []data.Work{ 50 | { 51 | Company: "Flingers", 52 | Position: "Burger Flipper", 53 | StartDate: "1993-08-01", 54 | EndDate: "1998-01-31", 55 | Summary: "Paying my way through school with an exciting opportunity in the fast-food service industry.", 56 | Highlights: []string{ 57 | "Wore 37 pieces of flair.", 58 | "A terrific smile.", 59 | }, 60 | }, 61 | }, 62 | AdditionalWorkLabel: "Academic Work Experience", 63 | Education: []data.Education{ 64 | { 65 | Institution: "University of Austin", 66 | Area: "B.S. Computer Science", 67 | StartDate: "1993-09-01", 68 | EndDate: "1997-12-01", 69 | }, 70 | }, 71 | Skills: []data.Skill{ 72 | { 73 | Name: "Programming", 74 | Level: "Mid-level", 75 | Keywords: []string{"C++", "Java"}, 76 | }, 77 | { 78 | Name: "Communication", 79 | Level: "Junior", 80 | Keywords: []string{"Verbal", "Written"}, 81 | }, 82 | }, 83 | Publications: []data.Publication{ 84 | { 85 | Name: "Money Laundering for Dummies", 86 | Publisher: "John Wiley & Sons", 87 | ReleaseDate: "1999-06-01", 88 | ISBN: "1234567890X", 89 | Summary: "Similar to the plot from \"Superman III\"", 90 | }, 91 | }, 92 | PublicationsLabel: "Publications", 93 | AdditionalPublications: []data.Publication{ 94 | { 95 | Name: "Washington High School Class of 1993 Yearbook", 96 | ReleaseDate: "1993-06-01", 97 | Summary: "Served as understudy to the assistant editor for my high school yearbook.", 98 | }, 99 | }, 100 | AdditionalPublicationsLabel: "Academic Publications", 101 | } 102 | return data 103 | } 104 | 105 | func DeleteFileIfExists(t *testing.T, filename string) { 106 | if _, err := os.Stat(filename); err == nil { 107 | err := os.Remove(filename) 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | } 112 | } 113 | --------------------------------------------------------------------------------