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:
parent
76fbe5c704
commit
fa8dbe68e9
13 changed files with 248 additions and 117 deletions
|
@ -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
|
||||
|
|
46
cv.go
46
cv.go
|
@ -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"`
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
2
go.mod
2
go.mod
|
@ -1,3 +1,3 @@
|
|||
module codeflow.dananglin.me.uk/apollo/spruce
|
||||
|
||||
go 1.19
|
||||
go 1.20
|
||||
|
|
25
internal/cv/cv.go
Normal file
25
internal/cv/cv.go
Normal 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
67
internal/cv/types.go
Normal 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
|
||||
}
|
36
internal/templateFuncs/formatduration.go
Normal file
36
internal/templateFuncs/formatduration.go
Normal 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
|
||||
}
|
11
internal/templateFuncs/join.go
Normal file
11
internal/templateFuncs/join.go
Normal 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, " ")
|
||||
}
|
7
internal/templateFuncs/notlastelement.go
Normal file
7
internal/templateFuncs/notlastelement.go
Normal 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
|
||||
}
|
23
internal/templateFuncs/withindaterange.go
Normal file
23
internal/templateFuncs/withindaterange.go
Normal 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
47
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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -29,6 +29,7 @@
|
|||
|
||||
\section{EXPERIENCE}
|
||||
<<- range .Employment>>
|
||||
<<- if withinTimePeriod .Duration>>
|
||||
\jobsection{<<.Company>>}{<<.Location>>}{<<.JobTitle>>}{<<durationToString .Duration>>}
|
||||
\startitemize
|
||||
<<range .Details>>
|
||||
|
@ -36,6 +37,7 @@
|
|||
<<end>>
|
||||
\stopitemize
|
||||
<<end>>
|
||||
<<end>>
|
||||
|
||||
\section{EDUCATION}
|
||||
<<range .Education ->>
|
||||
|
|
Loading…
Reference in a new issue