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.
This commit is contained in:
Dan Anglin 2023-03-02 17:40:04 +00:00
parent 76fbe5c704
commit fa8dbe68e9
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
13 changed files with 248 additions and 117 deletions

View file

@ -2,7 +2,7 @@
## Overview ## 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: 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. 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. - **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. - **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`: - For Ubuntu/Debian installation you can use `apt`:
```bash ```bash
$ apt install font-crosextra-carlito $ apt install font-crosextra-carlito

46
cv.go
View file

@ -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"`
}

View file

@ -45,10 +45,11 @@
"jobTitle": "Software Engineer (Cloud Platform)", "jobTitle": "Software Engineer (Cloud Platform)",
"duration": { "duration": {
"start": { "start": {
"year": "2020", "year": 2020,
"month": "August" "month": 4,
"day": 5
}, },
"present": "yes" "present": true
}, },
"details": [ "details": [
"Est doloremque molestiae ipsum id occaecati porro repudiandae corporis.", "Est doloremque molestiae ipsum id occaecati porro repudiandae corporis.",
@ -62,12 +63,14 @@
"jobTitle": "Software Engineer (Backend)", "jobTitle": "Software Engineer (Backend)",
"duration": { "duration": {
"start": { "start": {
"year": "2015", "year": 2015,
"month": "September" "month": 9,
"day": 3
}, },
"end": { "end": {
"year": "2021", "year": 2021,
"month": "August" "month": 8,
"day": 2
} }
}, },
"details": [ "details": [
@ -82,12 +85,14 @@
"jobTitle": "Software Engineer (Backend)", "jobTitle": "Software Engineer (Backend)",
"duration": { "duration": {
"start": { "start": {
"year": "2010", "year": 2010,
"month": "January" "month": 9,
"day": 3
}, },
"end": { "end": {
"year": "2015", "year": 2015,
"month": "September" "month": 9,
"day": 17
} }
}, },
"details": [ "details": [
@ -95,21 +100,45 @@
"Animi et sed nostrum, inventore debitis, quia voluptates dolor cum tempore vero commodi.", "Animi et sed nostrum, inventore debitis, quia voluptates dolor cum tempore vero commodi.",
"Distinctio sequi commodi debitis enim voluptas ex ipsum." "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": [ "education": [
{ {
"school": "Example University", "school": "Example University",
"location": "London", "location": "London",
"qualification": "BSc (Hons) in Computer Science & Artificial Intelligence, 1st", "qualification": "BSc (Hons) in Computer Science & Artificial Intelligence, 1st Class",
"duration": { "duration": {
"start": { "start": {
"year": "2012", "year": 2007,
"month": "October" "month": 10,
"day": 4
}, },
"end": { "end": {
"year": "2015", "year": 2010,
"month": "July" "month": 7,
"day": 30
} }
} }
} }

2
go.mod
View file

@ -1,3 +1,3 @@
module codeflow.dananglin.me.uk/apollo/spruce module codeflow.dananglin.me.uk/apollo/spruce
go 1.19 go 1.20

25
internal/cv/cv.go Normal file
View file

@ -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
}

67
internal/cv/types.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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, " ")
}

View file

@ -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
}

View file

@ -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
}
}

47
main.go
View file

@ -2,15 +2,18 @@ package main
import ( import (
"embed" "embed"
"encoding/json"
"flag" "flag"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"text/template" "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/* //go:embed templates/tex/*
@ -18,14 +21,24 @@ var templates embed.FS
func main() { func main() {
var input, output string 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(&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.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() 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-") tempDir, err := os.MkdirTemp("/tmp", "cv-builder-")
if err != nil { 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() { defer func() {
@ -35,26 +48,23 @@ func main() {
} }
}() }()
texFile, err := tex(input, tempDir) texFile, err := tex(input, tempDir, historyLimit)
if err != nil { 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 { 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. // tex generates the CV document as a Tex file.
func tex(input, tempDir string) (string, error) { func tex(input, tempDir string, historyLimit time.Time) (string, error) {
data, err := os.ReadFile(input) c, err := cv.NewCV(input)
if err != nil { if err != nil {
return "", fmt.Errorf("unable to read data from file; %w", err) return "", fmt.Errorf("unable to create a new CV value from %s; %w", input, err)
}
var cv Cv
if err = json.Unmarshal(data, &cv); err != nil {
return "", fmt.Errorf("unable to decode JSON data; %w", err)
} }
output := filepath.Join(tempDir, "cv.tex") output := filepath.Join(tempDir, "cv.tex")
@ -66,9 +76,10 @@ func tex(input, tempDir string) (string, error) {
defer file.Close() defer file.Close()
fmap := template.FuncMap{ fmap := template.FuncMap{
"notLastElement": notLastElement, "notLastElement": tf.NotLastElement,
"join": join, "join": tf.JoinSentences,
"durationToString": durationToString, "durationToString": tf.FormatDuration,
"withinTimePeriod": tf.WithinTimePeriod(historyLimit),
} }
t := template.Must(template.New("cv.tmpl.tex"). 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"), 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) return "", fmt.Errorf("unable to execute the CV template. %w", err)
} }

View file

@ -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
}

View file

@ -29,6 +29,7 @@
\section{EXPERIENCE} \section{EXPERIENCE}
<<- range .Employment>> <<- range .Employment>>
<<- if withinTimePeriod .Duration>>
\jobsection{<<.Company>>}{<<.Location>>}{<<.JobTitle>>}{<<durationToString .Duration>>} \jobsection{<<.Company>>}{<<.Location>>}{<<.JobTitle>>}{<<durationToString .Duration>>}
\startitemize \startitemize
<<range .Details>> <<range .Details>>
@ -36,6 +37,7 @@
<<end>> <<end>>
\stopitemize \stopitemize
<<end>> <<end>>
<<end>>
\section{EDUCATION} \section{EDUCATION}
<<range .Education ->> <<range .Education ->>