From fa8dbe68e919cdc1fe303c41343fecdad669142f Mon Sep 17 00:00:00 2001 From: Dan Anglin Date: Thu, 2 Mar 2023 17:40:04 +0000 Subject: [PATCH] feat(BREAKING): limit shown employment history Allow users to limit the amount of employment history shown in the PDF document by specifying a time range. This is a breaking change as the structure of the CV needs to slightly change. The employment's start and end dates need to be represented as integers. Additional refactoring: - The CV type is now in the internal cv package. - The template functions are now in the internal templateFuncs package. --- README.md | 4 +- cv.go | 46 ---------------- example/cv.json | 61 +++++++++++++++------ go.mod | 2 +- internal/cv/cv.go | 25 +++++++++ internal/cv/types.go | 67 +++++++++++++++++++++++ internal/templateFuncs/formatduration.go | 36 ++++++++++++ internal/templateFuncs/join.go | 11 ++++ internal/templateFuncs/notlastelement.go | 7 +++ internal/templateFuncs/withindaterange.go | 23 ++++++++ main.go | 47 ++++++++++------ template_funcs.go | 34 ------------ templates/tex/cv.tmpl.tex | 2 + 13 files changed, 248 insertions(+), 117 deletions(-) delete mode 100644 cv.go create mode 100644 internal/cv/cv.go create mode 100644 internal/cv/types.go create mode 100644 internal/templateFuncs/formatduration.go create mode 100644 internal/templateFuncs/join.go create mode 100644 internal/templateFuncs/notlastelement.go create mode 100644 internal/templateFuncs/withindaterange.go delete mode 100644 template_funcs.go diff --git a/README.md b/README.md index cb45c9d..2fe5abd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Overview -This project parses a CV, written in JSON and generates it as a PDF document. +Spruce is a tool that generates a PDF document from a CV written in JSON. The PDF generation is completed in two steps: 1. The application parses the JSON document and generates a TEX file using the TEX template files located in the [templates](./templates) directory. @@ -14,7 +14,7 @@ If you are interested in generating your own CV, then below is a list of depende - **Go:** Please go [here](https://go.dev/dl/) to download the latest version of the Go programming language. - **ConTeXt:** You can go to the [installation page](https://wiki.contextgarden.net/Installation) to find out how to install ConTeXt for your Operating System. -- **The Carlito font (ttf-carlito):** In previous iterations of my CV I used the Calibri font. Carlito is the free, metric compatible alternative to this and is specified in the TEX template. +- **The Carlito font (ttf-carlito):** A free, metric compatible alternative to the Calibri. - For Ubuntu/Debian installation you can use `apt`: ```bash $ apt install font-crosextra-carlito diff --git a/cv.go b/cv.go deleted file mode 100644 index a3c117a..0000000 --- a/cv.go +++ /dev/null @@ -1,46 +0,0 @@ -package main - -type Cv struct { - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - JobTitle string `json:"jobTitle"` - Contact map[string]string `json:"contact"` - Links map[string]string `json:"links"` - Summary []string `json:"summary"` - Skills []Skills `json:"skills"` - Employment []Experience `json:"employment"` - Education []Experience `json:"education"` - Interests []string `json:"interests"` -} - -type Contact struct { - Email string `json:"email"` - Phone string `json:"phone"` - Location string `json:"location"` -} - -type Skills struct { - Category string `json:"category"` - Values []string `json:"values"` -} - -type Experience struct { - Company string `json:"company,omitempty"` - School string `json:"school,omitempty"` - Location string `json:"location"` - JobTitle string `json:"jobTitle,omitempty"` - Qualification string `json:"qualification,omitempty"` - Duration Duration `json:"duration"` - Details []string `json:"details"` -} - -type Duration struct { - Start Date `json:"start"` - End Date `json:"end"` - Present string `json:"present"` -} - -type Date struct { - Year string `json:"year"` - Month string `json:"month"` -} diff --git a/example/cv.json b/example/cv.json index 797a222..b00cfba 100644 --- a/example/cv.json +++ b/example/cv.json @@ -45,10 +45,11 @@ "jobTitle": "Software Engineer (Cloud Platform)", "duration": { "start": { - "year": "2020", - "month": "August" + "year": 2020, + "month": 4, + "day": 5 }, - "present": "yes" + "present": true }, "details": [ "Est doloremque molestiae ipsum id occaecati porro repudiandae corporis.", @@ -62,12 +63,14 @@ "jobTitle": "Software Engineer (Backend)", "duration": { "start": { - "year": "2015", - "month": "September" + "year": 2015, + "month": 9, + "day": 3 }, "end": { - "year": "2021", - "month": "August" + "year": 2021, + "month": 8, + "day": 2 } }, "details": [ @@ -82,12 +85,14 @@ "jobTitle": "Software Engineer (Backend)", "duration": { "start": { - "year": "2010", - "month": "January" + "year": 2010, + "month": 9, + "day": 3 }, "end": { - "year": "2015", - "month": "September" + "year": 2015, + "month": 9, + "day": 17 } }, "details": [ @@ -95,21 +100,45 @@ "Animi et sed nostrum, inventore debitis, quia voluptates dolor cum tempore vero commodi.", "Distinctio sequi commodi debitis enim voluptas ex ipsum." ] + }, + { + "company": "Engineering Department, Example University", + "location": "London (Onsite)", + "jobTitle": "Research and Development Assistant", + "duration": { + "start": { + "year": 2009, + "month": 11, + "day": 13 + }, + "end": { + "year": 2010, + "month": 7, + "day": 30 + } + }, + "details": [ + "Harum voluptas labore impedit itaque animi repellendus dolorem, Impedit error esse hic at ea ut ab ipsam.", + "Repudiandae sed architecto quia temporibus eum corrupti.", + "Est libero consectetur laborum possimus, Deserunt ea pariatur quia veniam expedita facilis quis." + ] } ], "education": [ { "school": "Example University", "location": "London", - "qualification": "BSc (Hons) in Computer Science & Artificial Intelligence, 1st", + "qualification": "BSc (Hons) in Computer Science & Artificial Intelligence, 1st Class", "duration": { "start": { - "year": "2012", - "month": "October" + "year": 2007, + "month": 10, + "day": 4 }, "end": { - "year": "2015", - "month": "July" + "year": 2010, + "month": 7, + "day": 30 } } } diff --git a/go.mod b/go.mod index 9a5409b..45f902c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module codeflow.dananglin.me.uk/apollo/spruce -go 1.19 +go 1.20 diff --git a/internal/cv/cv.go b/internal/cv/cv.go new file mode 100644 index 0000000..1e3d1aa --- /dev/null +++ b/internal/cv/cv.go @@ -0,0 +1,25 @@ +package cv + +import ( + "os" + "fmt" + "encoding/json" +) + +// NewCV returns a new CV value from the given JSON file. +func NewCV(path string) (CV, error) { + file, err := os.Open(path) + if err != nil { + return CV{}, fmt.Errorf("unable to open %s; %w", path, err) + } + + decoder := json.NewDecoder(file) + + var c CV + + if err = decoder.Decode(&c); err != nil { + return CV{}, fmt.Errorf("unable to decode JSON data; %w", err) + } + + return c, nil +} diff --git a/internal/cv/types.go b/internal/cv/types.go new file mode 100644 index 0000000..b834e0e --- /dev/null +++ b/internal/cv/types.go @@ -0,0 +1,67 @@ +package cv + +import ( + "fmt" + "time" +) + +type CV struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + JobTitle string `json:"jobTitle"` + Contact map[string]string `json:"contact"` + Links map[string]string `json:"links"` + Summary []string `json:"summary"` + Skills []Skills `json:"skills"` + Employment []Experience `json:"employment"` + Education []Experience `json:"education"` + Interests []string `json:"interests"` +} + +type Skills struct { + Category string `json:"category"` + Values []string `json:"values"` +} + +type Experience struct { + Company string `json:"company"` + School string `json:"school"` + Location string `json:"location"` + JobTitle string `json:"jobTitle"` + Qualification string `json:"qualification"` + Duration Duration `json:"duration"` + Details []string `json:"details"` +} + +type Duration struct { + Start Date `json:"start"` + End Date `json:"end"` + Present bool `json:"present"` +} + +func (d Duration) After(t time.Time) (bool, error) { + endDate, err := d.End.ParseDate() + if err != nil { + return false, err + } + + return endDate.After(t), nil + +} + +type Date struct { + Year int `json:"year"` + Month int `json:"month"` + Day int `json:"day"` +} + +func (d Date) ParseDate() (time.Time, error) { + dateStr := fmt.Sprintf("%d-%02d-%02d", d.Year, d.Month, d.Day) + + date, err := time.Parse(time.DateOnly, dateStr) + if err != nil { + return time.Time{}, fmt.Errorf("unable to parse the date; %w", err) + } + + return date, nil +} diff --git a/internal/templateFuncs/formatduration.go b/internal/templateFuncs/formatduration.go new file mode 100644 index 0000000..741fcdd --- /dev/null +++ b/internal/templateFuncs/formatduration.go @@ -0,0 +1,36 @@ +package templates + +import ( + "fmt" + + "codeflow.dananglin.me.uk/apollo/spruce/internal/cv" +) + +// FormatDuration outputs the employment/education +// duration as a formatted string. +func FormatDuration(d cv.Duration) string { + var start string + + startDate, err := d.Start.ParseDate() + + if err != nil { + start = "Unknown" + } else { + start = fmt.Sprintf("%s, %d", startDate.Month().String(), startDate.Year()) + } + + var end string + + if d.Present { + end = "Present" + } else { + endDate, err := d.End.ParseDate() + if err != nil { + end = "Unknown" + } else { + end = fmt.Sprintf("%s, %d", endDate.Month().String(), endDate.Year()) + } + } + + return start + " - " + end +} diff --git a/internal/templateFuncs/join.go b/internal/templateFuncs/join.go new file mode 100644 index 0000000..8437668 --- /dev/null +++ b/internal/templateFuncs/join.go @@ -0,0 +1,11 @@ +package templates + +import ( + "strings" +) + +// JoinSentences uses strings.Join to join all string elements into +// a single string. +func JoinSentences(sentences []string) string { + return strings.Join(sentences, " ") +} diff --git a/internal/templateFuncs/notlastelement.go b/internal/templateFuncs/notlastelement.go new file mode 100644 index 0000000..5db5452 --- /dev/null +++ b/internal/templateFuncs/notlastelement.go @@ -0,0 +1,7 @@ +package templates + +// NotLastElement returns true if an element of an array +// or a slice is not the last. +func NotLastElement(pos, length int) bool { + return pos < length-1 +} diff --git a/internal/templateFuncs/withindaterange.go b/internal/templateFuncs/withindaterange.go new file mode 100644 index 0000000..4c4a91b --- /dev/null +++ b/internal/templateFuncs/withindaterange.go @@ -0,0 +1,23 @@ +package templates + +import ( + "time" + "codeflow.dananglin.me.uk/apollo/spruce/internal/cv" +) + +// WithinTimePeriod returns true if the employment's end date is within +// the specified time period. +func WithinTimePeriod(t time.Time) func(d cv.Duration) bool { + return func(d cv.Duration) bool { + if d.Present { + return true + } + + result, err := d.After(t) + if err != nil { + return false + } + + return result + } +} diff --git a/main.go b/main.go index f3238c4..f5286d1 100644 --- a/main.go +++ b/main.go @@ -2,15 +2,18 @@ package main import ( "embed" - "encoding/json" "flag" "fmt" + "io" "log" "os" "os/exec" "path/filepath" "text/template" - "io" + "time" + + "codeflow.dananglin.me.uk/apollo/spruce/internal/cv" + tf "codeflow.dananglin.me.uk/apollo/spruce/internal/templateFuncs" ) //go:embed templates/tex/* @@ -18,14 +21,24 @@ var templates embed.FS func main() { var input, output string + var employmentHistory int flag.StringVar(&input, "input", "", "specify the CV JSON file that you want to input to the builder.") flag.StringVar(&output, "output", "", "specify the name of the output CV file.") + flag.IntVar(&employmentHistory, "employment-history", 10, "show employment history within these number of years.") flag.Parse() + if err := run(input, output, employmentHistory); err != nil { + log.Fatalf("ERROR: %v", err) + } +} + +func run(input, output string, employmentHistory int) error { + historyLimit := time.Now().AddDate(-1*employmentHistory, 0, 0) + tempDir, err := os.MkdirTemp("/tmp", "cv-builder-") if err != nil { - log.Fatalf("ERROR: Unable to create a temporary directory; %v", err) + return fmt.Errorf("unable to create a temporary directory; %w", err) } defer func() { @@ -35,26 +48,23 @@ func main() { } }() - texFile, err := tex(input, tempDir) + texFile, err := tex(input, tempDir, historyLimit) if err != nil { - log.Fatalf("ERROR: %v", err) + return fmt.Errorf("unable to create the tex file; %w", err) } if err := pdf(tempDir, texFile, output); err != nil { - log.Fatalf("ERROR: %v", err) + return fmt.Errorf("unable to create the PDF file; %w", err) } + + return nil } // tex generates the CV document as a Tex file. -func tex(input, tempDir string) (string, error) { - data, err := os.ReadFile(input) +func tex(input, tempDir string, historyLimit time.Time) (string, error) { + c, err := cv.NewCV(input) if err != nil { - return "", fmt.Errorf("unable to read data from file; %w", err) - } - - var cv Cv - if err = json.Unmarshal(data, &cv); err != nil { - return "", fmt.Errorf("unable to decode JSON data; %w", err) + return "", fmt.Errorf("unable to create a new CV value from %s; %w", input, err) } output := filepath.Join(tempDir, "cv.tex") @@ -66,9 +76,10 @@ func tex(input, tempDir string) (string, error) { defer file.Close() fmap := template.FuncMap{ - "notLastElement": notLastElement, - "join": join, - "durationToString": durationToString, + "notLastElement": tf.NotLastElement, + "join": tf.JoinSentences, + "durationToString": tf.FormatDuration, + "withinTimePeriod": tf.WithinTimePeriod(historyLimit), } t := template.Must(template.New("cv.tmpl.tex"). @@ -77,7 +88,7 @@ func tex(input, tempDir string) (string, error) { ParseFS(templates, "templates/tex/*.tmpl.tex"), ) - if err = t.Execute(file, cv); err != nil { + if err = t.Execute(file, c); err != nil { return "", fmt.Errorf("unable to execute the CV template. %w", err) } diff --git a/template_funcs.go b/template_funcs.go deleted file mode 100644 index c8e7d2f..0000000 --- a/template_funcs.go +++ /dev/null @@ -1,34 +0,0 @@ -package main - -import ( - "fmt" - "strings" -) - -// notLastElement returns true if an element of an array -// or a slice is not the last. -func notLastElement(pos, length int) bool { - return pos < length-1 -} - -// join uses strings.Join to join all string elements into -// a single string. -func join(s []string) string { - return strings.Join(s, " ") -} - -// durationToString outputs the employment/education -// duration as a formatted string. -func durationToString(d Duration) string { - start := fmt.Sprintf("%s, %s", d.Start.Month, d.Start.Year) - present := strings.ToLower(d.Present) - end := "" - - if present == "yes" || present == "true" { - end = "Present" - } else { - end = fmt.Sprintf("%s, %s", d.End.Month, d.End.Year) - } - - return start + " - " + end -} diff --git a/templates/tex/cv.tmpl.tex b/templates/tex/cv.tmpl.tex index 24f2163..e76ae87 100644 --- a/templates/tex/cv.tmpl.tex +++ b/templates/tex/cv.tmpl.tex @@ -29,6 +29,7 @@ \section{EXPERIENCE} <<- range .Employment>> + <<- if withinTimePeriod .Duration>> \jobsection{<<.Company>>}{<<.Location>>}{<<.JobTitle>>}{<>} \startitemize <> @@ -36,6 +37,7 @@ <> \stopitemize <> + <> \section{EDUCATION} <>