Compare commits

...

25 commits

Author SHA1 Message Date
b2ce85b146
ci(fix): update environment variables
All checks were successful
/ test (pull_request) Successful in 25s
/ lint (pull_request) Successful in 25s
2023-12-06 14:48:20 +00:00
649f074d25
Update Project
All checks were successful
/ test (pull_request) Successful in 26s
/ lint (pull_request) Successful in 26s
Changes:

- ci: Add a workflow for Forgejo Actions
- docs: Update README.asciidoc
- tests: add a mage target for running tests
2023-12-06 14:42:18 +00:00
71d62ecaf6
refactor: add golangci-lint with code refactoring
Add golangci-lint for linting and refactor the code based on the
feedback from running it.

Changes:

- Add configuration for golangci-lint.
- Break the large function in create.go into smaller ones.
- Rename internal/templateFuncs to internal/templatefuncs to remove
  upper case letters in the package name.
- Add a mage target for lint tests.
2023-08-21 03:07:06 +01:00
4f90a4c8bb
fix: use default FlagSet for spruce help message
Use the default FlagSet to parse all the arguments and to set the
default help message for spruce. Arguments set after the subcommand are
still parsed by the subcommand's FlagSet.

The summaries for all subcommand are defined in one place in the main
function for consistency.

The internal/cmd.SpruceUsage function is replaced with the
spruceUsageFunc function in the main package which returns the usage
function which is set as the default usage function.

The format of the help message for spruce and the subcommands have been
updated with the inspiration of the help message from gopass.
2023-08-20 06:14:10 +01:00
8a530551c2
fix: create separate types for work and education
Create separate types for education and employment to fix the issue
where unnecessary fields are created under both education and employment
when a new CV is created.
2023-08-19 00:29:55 +01:00
9e0f5af2e6
fix: use JSON schema to auto-generate the CV type
- Use go generate and a third party tool called jsonschemagen to
  auto-generate the CV data type from the JSON schema.
- Update the schema by changing number to integer to ensure that the
  integers are set to the correct type.
- Refactor some code.
2023-08-18 23:47:41 +01:00
831e91a45d
fix: improve spruce-docgen
Improve spruce-docgen by improving how additionalProperties is
unmarshalled. If the value is either true or false then
AdditionalProperties is set to the default schema value.

Update getType to return discovered maps.

Update refType to return UNKNOWN if the ref string is invalid.

Regenerated docs/schema.asciidoc
2023-08-18 15:13:17 +01:00
446691719f
small bit of refactoring; refType validate ref string 2023-08-18 14:21:36 +01:00
1877f23a6e
docs: add section on creating CVs 2023-08-18 14:11:52 +01:00
b92ab4e437
feat: add JSON schema and document generator
Add the schema for the CV JSON documentation and add an internal tool
called spruce-docgen that generates an AsciiDoc referencing the schema.
2023-08-17 21:51:53 +01:00
d0d03f48b7
chore: move main.go to cmd/spruce
In preparation for additional binaries that may or may
not appear.
2023-08-16 23:33:28 +01:00
93da4f6648
fix: docker support improvements
- Build the binary with Mage.
- Set the ENTRYPOINT to "spruce".
- Add instructions on building and using the docker image.
2023-08-16 03:33:13 +01:00
c53978cd91
feat: add a field for location type, code refactor
- feat: add a field for the type of work location (e.g. hybrid)
- refactor: move the Tex and PDF generating code to a new internal
  package which also moves the templates there as well.
- fix: add a default value for the --output field for the generate
  command.
- fix: add an error for when the user does not specify an input file
  when generating the PDF.
- fix: the package name for each of the files in the templateFuncs
  package.
2023-08-15 17:25:00 +01:00
90638f5569
fix: customised usage messages
Add functionality to display the default help message when running
spruce without any arguments or when the help flag is used.

Customise the help message for the subcommands.

Additional changes:

- Refactor: move the Runner interface to the internal cmd package
- Fix: Add a summary for each of the subcommands.
- Refactor: Use string builder to replace string literals.
- Perf: Use a switch statement to only create the subcommand that the
  user calls.
2023-08-13 17:45:33 +01:00
050748d8cd
docs: replace local links with full URLs 2023-08-12 15:33:18 +01:00
82bdc231e3
docs: update README
- Convert README.md to README.asciidoc
- Update the requirements section
- Add installation section
2023-08-12 15:26:04 +01:00
2739e2fd28
build: fix the copying of the binary 2023-08-12 14:08:50 +01:00
2a6cc07624
fix: use slog for logging
- Created a new slog logger for logging.
- Optionally enable verbose logging when generating the PDF document.
2023-08-12 12:02:44 +01:00
2c5c7332be
refactor: move the subcommands to internal
Move the subcommands and FlagSets to a new internal package.
2023-08-12 10:26:23 +01:00
54d5fa1831
Merge branch 'create-subcommand' 2023-08-12 09:51:35 +01:00
36acb1a324
feat: add create subcommand and FlagSet
- Add a new subcommand and FlagSet for creating new CV JSON files.
- fix: close the file after reading the CV.
2023-08-12 09:43:45 +01:00
2c57c3c278
build(clean): list files to remove
Add a list of files to remove when cleaning the workspace.
2023-08-11 19:31:26 +01:00
b9dbdb2c61
refactor: embed *flag.FlagSet
Embed *flag.FlagSet directly into the subcommand types.
2023-08-11 19:26:24 +01:00
cee274318d
feat: add FlagSets to create new subcommands
Create new FlagSets to create new subcommands.

- The version subcommand prints the version and build info.
- The generate subcommand generates the CV PDF documentation.
2023-08-11 18:33:26 +01:00
69c3165fc1
build: replace make with mage
- Replace the makefile with the magefile
- Update the Dockerfile
- Update go.mod
2023-08-11 14:59:21 +01:00
35 changed files with 1718 additions and 342 deletions

View file

@ -1,5 +0,0 @@
*
!*.go
!templates/*
!go.mod
!go.sum

View file

@ -0,0 +1,37 @@
---
on:
pull_request:
types:
- opened
- reopened
- synchronize
jobs:
test:
runs-on: docker
env:
SPRUCE_TEST_VERBOSE: "1"
SPRUCE_TEST_COVER: "1"
steps:
- name: Checkout Repository
uses: https://code.forgejo.org/actions/checkout@v3
- name: Setup Go
uses: https://code.forgejo.org/actions/setup-go@v4
with:
go-version: '1.21'
- name: Test
run: go run magefiles/main.go -v test
lint:
runs-on: docker
steps:
- name: Checkout Repository
uses: https://code.forgejo.org/actions/checkout@v3
- name: Setup Go
uses: https://code.forgejo.org/actions/setup-go@v4
with:
go-version: '1.21'
- name: Lint
uses: https://github.com/golangci/golangci-lint-action@v3
with:
version: v1.54

3
.gitignore vendored
View file

@ -43,4 +43,5 @@ tags
[._]*.un~
cv.pdf
spruce
cv.json
/spruce

55
.golangci.yml Normal file
View file

@ -0,0 +1,55 @@
---
run:
concurrency: 2
timeout: 1m
issues-exit-code: 1
tests: true
modules-download-mode: readonly
output:
format: colored-line-number
print-issues-lines: true
print-linter-name: true
uniq-by-line: true
sort-results: true
linters:
enable-all: true
disable:
- exhaustruct
- exhaustivestruct # (deprecated)
- golint # (deprecated)
- ifshort # (deprecated)
- scopelint # (deprecated)
- maligned # (deprecated)
- deadcode # (deprecated)
- nosnakecase # (deprecated)
- interfacer # (deprecated)
- varcheck # (deprecated)
- structcheck # (deprecated)
fast: false
linters-settings:
depguard:
rules:
main:
files:
- $all
allow:
- $gostd
- codeflow.dananglin.me.uk/apollo/spruce
lll:
line-length: 140
issues:
exclude-rules:
- path: cmd/spruce/main.go
linters:
- gochecknoglobals
- path: cmd/spruce-docgen/main.go
linters:
- gochecknoglobals
- cyclop
- path: internal/cmd/generate.go
linters:
- gomnd

View file

@ -1,22 +1,32 @@
FROM golang:1.19-buster AS spruce-builder
# syntax=docker/dockerfile:1
FROM golang:1.21-alpine AS builder
ENV CGO_ENABLED=0
ENV GOOS=linux
ENV GOARCH=amd64
WORKDIR /tmp
RUN apk add --no-cache git \
&& git clone https://github.com/magefile/mage
WORKDIR /tmp/mage
RUN go run bootstrap.go
COPY . /workspace
WORKDIR /workspace
RUN go build -a -v -o /workspace/spruce
RUN mage build
FROM alpine:3.17
FROM alpine:3.18
COPY --from=spruce-builder /workspace/spruce /usr/local/bin
COPY --from=builder /workspace/spruce /usr/local/bin
# Install dependencies
RUN apk upgrade --no-cache \
&& apk add \
&& apk add --no-cache \
font-carlito \
aspell \
curl \
@ -33,3 +43,5 @@ RUN curl -LO http://lmtx.pragma-ade.com/install-lmtx/context-linuxmusl.zip \
ENV PATH=${PATH}:/opt/context/tex/texmf-linuxmusl/bin
WORKDIR /workspace
ENTRYPOINT ["spruce"]

View file

@ -1,21 +0,0 @@
BINARY = spruce
INSTALL_PREFIX ?= /usr/local
CGO_ENABLED ?= 0
GOOS ?= linux
GOARCH ?= amd64
VERSION = 0.1.0
LDFLAGS = "-s -w -X main.version=$(VERSION) -X main.installPrefix=$(INSTALL_PREFIX)"
$(BINARY):
go build -ldflags=$(LDFLAGS) -v -a -o $(BINARY)
install: spruce
cp -f $(BINARY) $(INSTALL_PREFIX)/bin
chmod 0755 $(INSTALL_PREFIX)/bin/$(BINARY)
uninstall:
rm -f $(INSTALL_PREFIX)/bin/$(BINARY)
clean:
go clean

210
README.asciidoc Normal file
View file

@ -0,0 +1,210 @@
= Spruce - A tool for building CVs
:toc:
:toclevels: 1
:toc-title: Contents
== Overview
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 https://codeflow.dananglin.me.uk/apollo/spruce/src/branch/main/internal/cmd/templates/tex[templates] directory.
2. The application then uses ConTeXt to generate the final PDF document from the generated TEX file.
== Requirements
Below is a list of required tools for installing and using spruce.
=== Go
A minimum version of Go 1.21.0 is required for installing spruce.
Please go https://go.dev/dl/[here] to download the latest version.
=== ConTeXt
ConTeXt is required for generating the PDF documentation.
You can go to the https://wiki.contextgarden.net/Installation[installation page] to find out how
to install ConTeXt for your Operating System.
=== The Carlito font (ttf-carlito)
Carlito is a free, metric compatible alternative to the Calibri font from Microsoft and is used when generating the PDF documentation.
For Debian/Ubuntu distributions you can use `apt` to install the font.
[source,console]
----
apt install font-crosextra-carlito
----
For Arch Linux you can use `pacman`.
[source,console]
----
pacman -S ttf-carlito
----
Alternatively you can download the font from https://fontlibrary.org/en/font/carlito[Font Library].
Once Carlito is installed you'll need to update ConTeXt so it can find the font when generating the PDF:
[source,console]
----
OSFONTDIR=/usr/share/fonts
mtxrun --script fonts --reload
----
=== Mage (Optional)
The project includes a https://codeflow.dananglin.me.uk/apollo/spruce/src/branch/main/magefiles/mage.go[magefile] for automating the build and installation of the spruce binary.
With Mage the build information is built into the binary when it is compiled.
You can visit the https://magefile.org[website] for instructions on how to install Mage.
=== Docker (Optional)
You can use Docker to build and use a docker image built with spruce and ConText installed.
This could be useful for those who are comfortable using Docker and don't want to install the above dependencies
directly onto their machines.
== Installation
=== With Mage
You can install spruce with Mage using the following commands:
[source,console]
----
git clone https://codeflow.dananglin.me.uk/apollo/spruce.git
cd spruce
mage install
----
The default install prefix is set to `/usr/local` so spruce will be installed to `/usr/local/bin/spruce`.
If you don't have sudo privileges or you want to change the install prefix you can set the `SPRUCE_INSTALL_PREFIX` environment variable before installing.
[source,console]
----
SPRUCE_INSTALL_PREFIX=~/.local mage install
----
=== With Go
If your `GOBIN` directory is included in your `PATH` then you can install spruce with Go.
[source,console]
----
git clone https://codeflow.dananglin.me.uk/apollo/spruce.git
cd spruce
go install ./cmd/spruce
----
=== Building the Docker image
You can build a docker image using the Dockerfile included in this project.
The build will build and install the spruce binary as well as install all the required dependencies in the final image.
You can build the docker image with the following command:
[source,console]
----
docker build -t spruce .
----
=== Verifying the installation
Run `spruce` to verify your installation. You should see the usage printed onto your screen.
[source,console]
----
$ spruce
SUMMARY:
spruce - A command-line tool for building CVs
VERSION:
v0.3.0
USAGE:
spruce [flags]
spruce [command]
COMMANDS:
create creates a new CV JSON file
generate generates a PDF file from an existing CV JSON file
version print the application's version and build information
FLAGS:
-help, --help
print the help message
Use "spruce [command] --help" for more information about a command.
----
If you've build the docker image you can get the same help message with the following command:
[source,console]
----
docker run --rm -it spruce
----
If you have installed spruce with Mage, you can get the build information to confirm that you have installed the correct version.
[source,console]
----
$ spruce version --full
Spruce
Version: v0.1.0-14-ge503dbf
Git commit: e503dbf
Go version: go1.21.0
Build date: 2023-08-12T13:00:51Z
----
If you've built the docker image you can verify it with the following command:
[source,console]
----
docker run --rm spruce version --full
----
== Generating the example PDF Document
Once you've installed spruce you can generate a PDF file from the example CV by running the following command:
[source,console]
----
spruce generate --input example/cv.json
----
This will create a file called `cv.pdf` which you can view with your favourite PDF viewer.
If you're using the docker image you can generate the PDF file using the following command:
[source,console]
----
docker run --rm -v ./example:/workspace spruce generate --input cv.json --output cv.pdf
----
The PDF file will be created in the `example` folder.
== Creating your own CV
To create your own CV run `spruce create`.
You can use additional flags to populate the CV with basic details such as your first name, last name and current job title.
Run `spruce create --help` to see all available flags.
After executing this command you'll find a file called **cv.json** which will contain the skeleton of your CV JSON document.
[source,console]
----
$ spruce create
│time=2023-08-18T13:21:10.120+01:00 level=INFO msg="CV successfully created" filename=cv.json
----
You can now start populating the fields with your favourite text editor.
Please refer to https://codeflow.dananglin.me.uk/apollo/spruce/src/branch/main/docs/schema.asciidoc[the schema reference] for more information on each field.
You can also check out the https://codeflow.dananglin.me.uk/apollo/spruce/src/branch/main/example/cv.json[example CV] as an additional guide.
Once you're happy with your CV you can run `spruce generate` to generate the PDF documentation.
[source,console]
----
$ spruce generate
time=2023-08-18T13:32:03.240+01:00 level=INFO msg="Creating the Tex file."
time=2023-08-18T13:32:03.252+01:00 level=INFO msg="Tex file successfully created." filename=/tmp/cv-builder-1016910977/cv.tex
time=2023-08-18T13:32:03.252+01:00 level=INFO msg="Creating the PDF document."
time=2023-08-18T13:32:06.416+01:00 level=INFO msg="PDF document successfully created." filename=/tmp/cv-builder-1016910977/cv.pdf
time=2023-08-18T13:32:06.416+01:00 level=INFO msg="File successfully copied." source=/tmp/cv-builder-1016910977/cv.pdf destination=cv.pdf
----
== Inspirations
- https://mszep.github.io/pandoc_resume/[The Markdown Resume:] This project uses ConTeXt and pandoc to convert Markdown based CVs into multiple formats including PDF, HTML and DOCX. This is where I discovered ConTeXt.
- https://github.com/melkir/resume/tree/master[melkir/resume:] This project generates CVs using Go and LaTeX.

View file

@ -1,46 +0,0 @@
# Spruce - A tool for building CVs
## Overview
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.
2. The application then uses ConTeXt to generate the final PDF document from the generated TEX file.
## Dependencies
If you are interested in generating your own CV, then below is a list of dependencies that you'll need to install:
- **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):** A free, metric compatible alternative to the Calibri.
- For Ubuntu/Debian installation you can use `apt`:
```bash
$ apt install font-crosextra-carlito
```
- For Arch Linux you can use `pacman`:
```bash
$ pacman -S ttf-carlito
```
- Alternatively you can download the font from https://fontlibrary.org/en/font/carlito
- Once this font is installed you'll need to update ConTeXt so it can find the font when generating the PDF:
```bash
$ OSFONTDIR=/usr/share/fonts
$ mtxrun --script fonts --reload
```
## Generating the example PDF Document
You can generate a PDF from the example CV by running the following command:
```bash
$ go run . --input example/cv.json
```
This will create a file called `cv.pdf` which you can view in your favourite PDF viewer.
## Inspirations
- [The Markdown Resume](https://mszep.github.io/pandoc_resume/) - This project uses ConTeXt and pandoc to convert Markdown based CVs into multiple formats including PDF, HTML and DOCX. This is where I discovered ConTeXt.
- [melkir/resume](https://github.com/melkir/resume/tree/master) - This project generates CVs using Go and LaTeX.

165
cmd/spruce-docgen/main.go Normal file
View file

@ -0,0 +1,165 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"os"
"strings"
"text/template"
"unicode"
)
var schemaReferenceTemplate = `= JSON schema reference
NOTE: This page was auto-generated with spruce-docgen.
== {{ .Title }}
{{ .Description }}
[%header,cols=3*]
|===
|Field
|Type
|Description
{{- range $key, $property := .Properties }}
{{ print "" }}
|{{ $key }}
|{{ type $property }}
|{{ $property.Description }}
{{- end -}}{{ print "" }}
|===
{{ print "" }}
{{- range $i, $schema := .Defs }}
=== {{ capitalise $i }}
{{ print "" }}
[%header,cols=3*]
|===
|Field
|Type
|Description
{{- range $key, $property := $schema.Properties }}
{{ print "" }}
|{{ $key }}
|{{ type $property }}
|{{ $property.Description }}
{{- end -}}{{ print "" }}
|===
{{ print "" }}
{{- end }}
`
// schema minimally represents the JSON schema format.
type schema struct {
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
Properties map[string]*schema `json:"properties"`
Items *schema `json:"items"`
Required []string `json:"required"`
Ref string `json:"$ref"`
Defs map[string]*schema `json:"$defs"`
AdditionalProperties *schema `json:"additionalProperties"`
}
func (s *schema) UnmarshalJSON(data []byte) error {
if bytes.Equal(data, []byte("true")) || bytes.Equal(data, []byte("false")) {
*s = schema{}
} else {
type rawSchema schema
var res rawSchema
if err := json.Unmarshal(data, &res); err != nil {
return fmt.Errorf("unable to unmarshal to rawSchema; %w", err)
}
*s = schema(res)
}
return nil
}
func main() {
schemaFile := "./schema/cv.schema.json"
file, err := os.Open(schemaFile)
if err != nil {
log.Fatal(err)
}
defer file.Close()
decoder := json.NewDecoder(file)
var data schema
if err := decoder.Decode(&data); err != nil {
log.Panic(err)
}
funcMap := template.FuncMap{
"capitalise": title,
"type": getType,
}
t := template.Must(template.New("asciidoc").Funcs(funcMap).Parse(schemaReferenceTemplate))
if err = t.Execute(os.Stdout, data); err != nil {
log.Panic(err)
}
}
func title(str string) string {
runes := []rune(str)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
const (
typeArray = "array"
typeObject = "object"
)
func getType(data schema) string {
if data.Type != "" && data.Type != typeArray && data.Type != typeObject {
return data.Type
}
if data.Type == typeArray {
switch {
case data.Items == nil:
return "list(UNKNOWN)"
case data.Items.Type != "":
return "list(" + data.Items.Type + ")"
case data.Items.Ref != "":
return "list(<<" + title(refType(data.Items.Ref)) + ">>)"
default:
return "list(UNKNOWN)"
}
}
if data.Type == "" && data.Ref != "" {
return "<<" + title(refType(data.Ref)) + ">>"
}
if data.Type == typeObject {
if data.AdditionalProperties.Type != "" {
return "map(" + data.AdditionalProperties.Type + ")"
}
return typeObject
}
return "UNKNOWN"
}
func refType(str string) string {
prefix := "#/$defs/"
if !strings.HasPrefix(str, prefix) {
return "UNKNOWN"
}
return strings.TrimPrefix(str, prefix)
}

123
cmd/spruce/main.go Normal file
View file

@ -0,0 +1,123 @@
package main
import (
"flag"
"fmt"
"log/slog"
"os"
"slices"
"strings"
"codeflow.dananglin.me.uk/apollo/spruce/internal/cmd"
)
const (
create string = "create"
generate string = "generate"
version string = "version"
)
var (
binaryVersion string
buildTime string
goVersion string
gitCommit string
)
func main() {
summaries := map[string]string{
create: "creates a new CV JSON file",
generate: "generates a PDF file from an existing CV JSON file",
version: "print the application's version and build information",
}
flag.Usage = spruceUsageFunc(summaries)
flag.Parse()
if flag.NArg() < 1 {
flag.Usage()
os.Exit(0)
}
subcommand := flag.Arg(0)
args := flag.Args()[1:]
logOptions := slog.HandlerOptions{
AddSource: false,
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &logOptions))
slog.SetDefault(logger)
var runner cmd.Runner
switch subcommand {
case create:
runner = cmd.NewCreateCommand(create, summaries[create])
case generate:
runner = cmd.NewGenerateCommand(generate, summaries[generate])
case version:
runner = cmd.NewVersionCommand(
binaryVersion,
buildTime,
goVersion,
gitCommit,
version,
summaries[version],
)
default:
slog.Error("unknown subcommand", "subcommand", subcommand)
flag.Usage()
os.Exit(1)
}
if err := runner.Parse(args); err != nil {
slog.Error(fmt.Sprintf("unable to parse the command line flags; %v.", err))
os.Exit(1)
}
if err := runner.Run(); err != nil {
slog.Error(fmt.Sprintf("unable to run %q; %v.", runner.Name(), err))
os.Exit(1)
}
}
func spruceUsageFunc(summaries map[string]string) func() {
cmds := make([]string, len(summaries))
ind := 0
for k := range summaries {
cmds[ind] = k
ind++
}
slices.Sort(cmds)
return func() {
var builder strings.Builder
builder.WriteString("SUMMARY:\n spruce - A command-line tool for building CVs\n\n")
if binaryVersion != "" {
builder.WriteString("VERSION:\n " + binaryVersion + "\n\n")
}
builder.WriteString("USAGE:\n spruce [flags]\n spruce [command]\n\nCOMMANDS:")
for _, cmd := range cmds {
fmt.Fprintf(&builder, "\n %s\t%s", cmd, summaries[cmd])
}
builder.WriteString("\n\nFLAGS:\n -help, --help\n print the help message\n")
flag.VisitAll(func(f *flag.Flag) {
fmt.Fprintf(&builder, "\n -%s, --%s\n %s\n", f.Name, f.Name, f.Usage)
})
builder.WriteString("\nUse \"spruce [command] --help\" for more information about a command.\n")
w := flag.CommandLine.Output()
fmt.Fprint(w, builder.String())
}
}

175
docs/schema.asciidoc Normal file
View file

@ -0,0 +1,175 @@
= JSON schema reference
NOTE: This page was auto-generated with spruce-docgen.
== CV
A short written summary of your skills, achievements and experiences in relation to your role.
[%header,cols=3*]
|===
|Field
|Type
|Description
|contact
|map(string)
|Your contact information. You can use any key/value pairs here.
|education
|list(<<Education>>)
|A list of your education experiences.
|employment
|list(<<Employment>>)
|A list of your employment history.
|firstName
|string
|Your first name(s).
|interests
|list(string)
|A list of sentences summarising your interests and hobbies.
|jobTitle
|string
|Your current job title.
|lastName
|string
|Your last name.
|links
|map(string)
|URLs to your online presence such as GitHub or your website. You can use any key/value pairs here.
|skills
|list(<<Skills>>)
|A categorised list of your skills.
|summary
|list(string)
|A list of sentences summarising your skills, experiences and what you'd like to achieve in the near future.
|===
=== Date
[%header,cols=3*]
|===
|Field
|Type
|Description
|day
|integer
|The day of the month.
|month
|integer
|The numerical value of the month (e.g. 5 for May).
|year
|integer
|The year (e.g. 2023).
|===
=== Duration
[%header,cols=3*]
|===
|Field
|Type
|Description
|end
|<<Date>>
|The end date of the experience.
|present
|boolean
|Specifies whether you are currently employed or studying at the specified company or educational institute.
|start
|<<Date>>
|The start date of the experience.
|===
=== Education
[%header,cols=3*]
|===
|Field
|Type
|Description
|details
|list(string)
|Further details of the experience.
|duration
|<<Duration>>
|The duration of the experience.
|location
|string
|The location where the experience was based.
|qualification
|string
|The qualifications gained from this educational experience.
|school
|string
|The school or university where you have studied.
|===
=== Employment
[%header,cols=3*]
|===
|Field
|Type
|Description
|company
|string
|The company where your work experience took place.
|details
|list(string)
|Further details of the experience (e.g. achievements, daily responsibilities, etc).
|duration
|<<Duration>>
|The duration of the experience.
|jobTitle
|string
|The job title of your experience.
|location
|string
|The location where the experience was based.
|locationType
|string
|The location type of your work experience (e.g. Remote, Hybrid, On-site).
|===
=== Skills
[%header,cols=3*]
|===
|Field
|Type
|Description
|category
|string
|The skills category.
|values
|list(string)
|The skills listed in this category.
|===

View file

@ -41,7 +41,8 @@
"employment": [
{
"company": "Company C",
"location": "Manchester (Remote)",
"location": "Manchester",
"locationType": "Remote",
"jobTitle": "Software Engineer (Cloud Platform)",
"duration": {
"start": {
@ -59,7 +60,8 @@
},
{
"company": "Company B",
"location": "London (Hybrid)",
"location": "London",
"locationType": "Hybrid",
"jobTitle": "Software Engineer (Backend)",
"duration": {
"start": {
@ -81,7 +83,8 @@
},
{
"company": "Company A",
"location": "London (Onsite)",
"location": "London",
"locationType": "On-site",
"jobTitle": "Software Engineer (Backend)",
"duration": {
"start": {
@ -103,7 +106,8 @@
},
{
"company": "Engineering Department, Example University",
"location": "London (Onsite)",
"location": "London",
"locationType": "On-site",
"jobTitle": "Research and Development Assistant",
"duration": {
"start": {

4
go.mod
View file

@ -1,3 +1,5 @@
module codeflow.dananglin.me.uk/apollo/spruce
go 1.20
go 1.21
require github.com/magefile/mage v1.15.0

2
go.sum
View file

@ -0,0 +1,2 @@
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=

31
internal/cmd/cmd.go Normal file
View file

@ -0,0 +1,31 @@
package cmd
import (
"flag"
"fmt"
"strings"
)
type Runner interface {
Parse([]string) error
Name() string
Run() error
}
func usageFunc(name, summary string, flagset *flag.FlagSet) func() {
return func() {
var builder strings.Builder
fmt.Fprintf(&builder, "SUMMARY:\n %s - %s\n\nUSAGE:\n spruce %s [flags]\n\nFLAGS:", name, summary, name)
flagset.VisitAll(func(f *flag.Flag) {
fmt.Fprintf(&builder, "\n -%s, --%s\n %s", f.Name, f.Name, f.Usage)
})
builder.WriteString("\n")
w := flag.CommandLine.Output()
fmt.Fprint(w, builder.String())
}
}

142
internal/cmd/create.go Normal file
View file

@ -0,0 +1,142 @@
package cmd
import (
"encoding/json"
"flag"
"fmt"
"log/slog"
"os"
"time"
"codeflow.dananglin.me.uk/apollo/spruce/internal/cv"
)
type CreateCommand struct {
*flag.FlagSet
summary string
firstName string
lastName string
jobTitle string
filename string
}
func NewCreateCommand(name, summary string) *CreateCommand {
command := CreateCommand{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
summary: summary,
}
command.StringVar(&command.filename, "filepath", "cv.json", "specify the output path of the CV JSON file.")
command.StringVar(&command.firstName, "first-name", "", "specify your first name.")
command.StringVar(&command.jobTitle, "job-title", "", "specify your current job title.")
command.StringVar(&command.lastName, "last-name", "", "specify your last name.")
command.Usage = usageFunc(command.Name(), command.summary, command.FlagSet)
return &command
}
func (c *CreateCommand) Run() error {
detailLen := 2
data := cv.NewCV(c.firstName, c.lastName, c.jobTitle)
data.Contact = contact()
data.Links = links()
data.Summary = make([]string, detailLen)
data.Skills = skills()
data.Employment = employment(detailLen)
data.Education = education()
data.Interests = make([]string, detailLen)
file, err := os.Create(c.filename)
if err != nil {
return fmt.Errorf("unable to open %s; %w", c.filename, err)
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(data); err != nil {
return fmt.Errorf("unable to write the data to %s; %w", c.filename, err)
}
slog.Info("CV successfully created", "filename", file.Name())
return nil
}
func contact() map[string]string {
return map[string]string{
"Email": "",
"Phone": "",
}
}
func links() map[string]string {
return map[string]string{
"GitHub": "",
"Website": "",
}
}
func skills() []cv.Skills {
return []cv.Skills{
{
Category: "",
Values: make([]string, 1),
},
{
Category: "",
Values: make([]string, 1),
},
}
}
func employment(detailLen int) []cv.Employment {
return []cv.Employment{
{
Company: "",
Location: "",
LocationType: "",
JobTitle: "",
Duration: cv.Duration{
Start: cv.Date{
Day: int64(time.Now().Day()),
Month: int64(time.Now().Month()),
Year: int64(time.Now().Year()),
},
End: &cv.Date{
Day: int64(time.Now().Day()),
Month: int64(time.Now().Month()),
Year: int64(time.Now().Year()),
},
Present: false,
},
Details: make([]string, detailLen),
},
}
}
func education() []cv.Education {
return []cv.Education{
{
School: "",
Location: "",
Qualification: "",
Duration: cv.Duration{
Start: cv.Date{
Year: int64(time.Now().Year()),
Month: int64(time.Now().Month()),
Day: int64(time.Now().Day()),
},
End: &cv.Date{
Year: int64(time.Now().Year()),
Month: int64(time.Now().Month()),
Day: int64(time.Now().Day()),
},
},
},
}
}

98
internal/cmd/generate.go Normal file
View file

@ -0,0 +1,98 @@
package cmd
import (
"flag"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"time"
"codeflow.dananglin.me.uk/apollo/spruce/internal/pdf"
)
type GenerateCommand struct {
*flag.FlagSet
summary string
input string
output string
employmentHistory int
verbose bool
}
type noInputSpecifiedError struct{}
func (e noInputSpecifiedError) Error() string {
return "no input file specified, please set the --input field"
}
func NewGenerateCommand(name, summary string) *GenerateCommand {
command := GenerateCommand{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
summary: summary,
}
command.StringVar(&command.input, "input", "", "specify the CV JSON file that you want to input to the builder.")
command.StringVar(&command.output, "output", "cv.pdf", "specify the name of the output CV file.")
command.IntVar(&command.employmentHistory, "employment-history", 10, "show employment history within these number of years.")
command.BoolVar(&command.verbose, "verbose", false, "set to true to enable verbose logging.")
command.Usage = usageFunc(command.Name(), command.summary, command.FlagSet)
return &command
}
func (c *GenerateCommand) Run() error {
if c.input == "" {
return noInputSpecifiedError{}
}
historyLimit := time.Now().AddDate(-1*c.employmentHistory, 0, 0)
tempDir, err := os.MkdirTemp("/tmp", "cv-builder-")
if err != nil {
return fmt.Errorf("unable to create a temporary directory; %w", err)
}
defer func() {
err := os.RemoveAll(tempDir)
if err != nil {
slog.Warn(fmt.Sprintf("WARN: An error occurred when removing the temporary directory; %v", err))
}
}()
pdfFileName, err := pdf.Generate(tempDir, c.input, historyLimit, c.verbose)
if err != nil {
return fmt.Errorf("unable to create the PDF file; %w", err)
}
if err := copyfile(filepath.Join(tempDir, "cv.pdf"), c.output); err != nil {
return fmt.Errorf("unable to copy %s to %s; %w", pdfFileName, c.output, err)
}
return nil
}
func copyfile(source, destination string) error {
inputFile, err := os.Open(source)
if err != nil {
return fmt.Errorf("unable to open %s; %w", source, err)
}
defer inputFile.Close()
outputFile, err := os.Create(destination)
if err != nil {
return fmt.Errorf("unable to create %s; %w", destination, err)
}
defer outputFile.Close()
_, err = io.Copy(outputFile, inputFile)
if err != nil {
return fmt.Errorf("unable to copy %s to %s; %w", source, destination, err)
}
slog.Info("File successfully copied.", "source", source, "destination", destination)
return nil
}

56
internal/cmd/version.go Normal file
View file

@ -0,0 +1,56 @@
package cmd
import (
"flag"
"fmt"
"os"
"strings"
)
type VersionCommand struct {
*flag.FlagSet
summary string
fullVersion bool
binaryVersion string
buildTime string
goVersion string
gitCommit string
}
func NewVersionCommand(binaryVersion, buildTime, goVersion, gitCommit, name, summary string) *VersionCommand {
command := VersionCommand{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
binaryVersion: binaryVersion,
buildTime: buildTime,
goVersion: goVersion,
gitCommit: gitCommit,
summary: summary,
}
command.BoolVar(&command.fullVersion, "full", false, "prints the full build information")
command.Usage = usageFunc(command.Name(), command.summary, command.FlagSet)
return &command
}
func (c *VersionCommand) Run() error {
var builder strings.Builder
if c.fullVersion {
fmt.Fprintf(
&builder,
"Spruce\n Version: %s\n Git commit: %s\n Go version: %s\n Build date: %s\n",
c.binaryVersion,
c.gitCommit,
c.goVersion,
c.buildTime,
)
} else {
fmt.Fprintln(&builder, c.binaryVersion)
}
fmt.Fprint(os.Stdout, builder.String())
return nil
}

View file

@ -1,25 +1,52 @@
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
/*
Code generated by jsonschemagen.
DO NOT EDIT.
*/
type CV struct {
Contact map[string]string `json:"contact,omitempty"`
Education []Education `json:"education"`
Employment []Employment `json:"employment"`
FirstName string `json:"firstName"`
Interests []string `json:"interests"`
JobTitle string `json:"jobTitle"`
LastName string `json:"lastName"`
Links map[string]string `json:"links,omitempty"`
Skills []Skills `json:"skills"`
Summary []string `json:"summary"`
}
type Date struct {
Day int64 `json:"day"`
Month int64 `json:"month"`
Year int64 `json:"year"`
}
type Duration struct {
End *Date `json:"end,omitempty"`
Present bool `json:"present,omitempty"`
Start Date `json:"start"`
}
type Education struct {
Details []string `json:"details,omitempty"`
Duration Duration `json:"duration"`
Location string `json:"location"`
Qualification string `json:"qualification"`
School string `json:"school"`
}
type Employment struct {
Company string `json:"company"`
Details []string `json:"details,omitempty"`
Duration Duration `json:"duration"`
JobTitle string `json:"jobTitle"`
Location string `json:"location"`
LocationType string `json:"locationType,omitempty"`
}
type Skills struct {
Category string `json:"category"`
Values []string `json:"values"`
}

View file

@ -1,67 +0,0 @@
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
}

62
internal/cv/utils.go Normal file
View file

@ -0,0 +1,62 @@
package cv
import (
"encoding/json"
"fmt"
"os"
"time"
)
// NewCVFromFile returns a new CV value from the given JSON file.
func NewCVFromFile(path string) (CV, error) {
file, err := os.Open(path)
if err != nil {
return CV{}, fmt.Errorf("unable to open %s; %w", path, err)
}
defer file.Close()
decoder := json.NewDecoder(file)
var output CV
if err = decoder.Decode(&output); err != nil {
return CV{}, fmt.Errorf("unable to decode JSON data; %w", err)
}
return output, nil
}
// NewCV returns a new value of type CV.
func NewCV(firstName, lastName, jobTitle string) CV {
output := CV{
FirstName: firstName,
LastName: lastName,
JobTitle: jobTitle,
}
return output
}
// After returns true if the Duration's end date is set after the earliest experience date.
// An error is returned if the end date is not parsed successfully.
func (d Duration) After(earliestExperienceDate time.Time) (bool, error) {
endDate, err := d.End.Parse()
if err != nil {
return false, err
}
return endDate.After(earliestExperienceDate), nil
}
// Parse parses Date and returns a value of type time.Time.
// An error is returned if the parsing fails.
func (d Date) Parse() (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
}

3
internal/internal.go Normal file
View file

@ -0,0 +1,3 @@
package internal
//go:generate jsonschemagen -s ../schema/cv.schema.json -o cv/cv.go -n cv -r CV -c "Code generated by jsonschemagen." -c "DO NOT EDIT."

39
internal/pdf/pdf.go Normal file
View file

@ -0,0 +1,39 @@
package pdf
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"time"
)
// Generate generates the CV PDF document from the JSON file.
func Generate(tempDir, input string, historyLimit time.Time, verbose bool) (string, error) {
texFileName, err := tex(input, tempDir, historyLimit)
if err != nil {
return "", fmt.Errorf("unable to create the tex file; %w", err)
}
slog.Info("Creating the PDF document.")
pathArg := "--path=" + tempDir
command := exec.Command("mtxrun", pathArg, "--script", "context", texFileName)
if verbose {
command.Stderr = os.Stderr
command.Stdout = os.Stdout
}
if err := command.Run(); err != nil {
return "", fmt.Errorf("an error occurred when creating the PDF file; %w", err)
}
output := filepath.Join(tempDir, "cv.pdf")
slog.Info("PDF document successfully created.", "filename", output)
return output, nil
}

View file

@ -30,7 +30,7 @@
\section{EXPERIENCE}
<<- range .Employment>>
<<- if withinTimePeriod .Duration>>
\jobsection{<<.Company>>}{<<.Location>>}{<<.JobTitle>>}{<<durationToString .Duration>>}
\jobsection{<<.Company>>}{<<location .Location .LocationType>>}{<<.JobTitle>>}{<<durationToString .Duration>>}
\startitemize
<<range .Details>>
\item <<.>>

57
internal/pdf/tex.go Normal file
View file

@ -0,0 +1,57 @@
package pdf
import (
"embed"
"fmt"
"log/slog"
"os"
"path/filepath"
"text/template"
"time"
"codeflow.dananglin.me.uk/apollo/spruce/internal/cv"
tf "codeflow.dananglin.me.uk/apollo/spruce/internal/templatefuncs"
)
//go:embed templates/tex/*
var templates embed.FS
// tex generates the CV document as a Tex file.
func tex(input, tempDir string, historyLimit time.Time) (string, error) {
slog.Info("Creating the Tex file.")
data, err := cv.NewCVFromFile(input)
if err != nil {
return "", fmt.Errorf("unable to create a new CV value from %s; %w", input, err)
}
output := filepath.Join(tempDir, "cv.tex")
file, err := os.Create(output)
if err != nil {
return "", fmt.Errorf("unable to create output file %s; %w", output, err)
}
defer file.Close()
fmap := template.FuncMap{
"notLastElement": tf.NotLastElement,
"join": tf.JoinSentences,
"durationToString": tf.FormatDuration,
"withinTimePeriod": tf.WithinTimePeriod(historyLimit),
"location": tf.Location,
}
tmpl := template.Must(template.New("cv.tmpl.tex").
Funcs(fmap).
Delims("<<", ">>").
ParseFS(templates, "templates/tex/*.tmpl.tex"),
)
if err = tmpl.Execute(file, data); err != nil {
return "", fmt.Errorf("unable to execute the CV template. %w", err)
}
slog.Info("Tex file successfully created.", "filename", output)
return output, nil
}

View file

@ -1,4 +1,4 @@
package templates
package templatefuncs
import (
"fmt"
@ -8,10 +8,10 @@ import (
// FormatDuration outputs the employment/education
// duration as a formatted string.
func FormatDuration(d cv.Duration) string {
func FormatDuration(duration cv.Duration) string {
var start string
startDate, err := d.Start.ParseDate()
startDate, err := duration.Start.Parse()
if err != nil {
start = "Unknown"
@ -21,10 +21,10 @@ func FormatDuration(d cv.Duration) string {
var end string
if d.Present {
if duration.Present {
end = "Present"
} else {
endDate, err := d.End.ParseDate()
endDate, err := duration.End.Parse()
if err != nil {
end = "Unknown"
} else {

View file

@ -1,4 +1,4 @@
package templates
package templatefuncs
import (
"strings"

View file

@ -0,0 +1,11 @@
package templatefuncs
// Location returns a string with both the location and the
// location type if the location type is not empty.
func Location(location, locationType string) string {
if locationType != "" {
return location + " (" + locationType + ")"
}
return location
}

View file

@ -1,4 +1,4 @@
package templates
package templatefuncs
// NotLastElement returns true if an element of an array
// or a slice is not the last.

View file

@ -1,19 +1,20 @@
package templates
package templatefuncs
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 {
func WithinTimePeriod(date time.Time) func(d cv.Duration) bool {
return func(d cv.Duration) bool {
if d.Present {
return true
}
result, err := d.After(t)
result, err := d.After(date)
if err != nil {
return false
}

132
magefiles/mage.go Normal file
View file

@ -0,0 +1,132 @@
//go:build mage
package main
import (
"fmt"
"os"
"path/filepath"
"runtime"
"time"
"github.com/magefile/mage/mg"
"github.com/magefile/mage/sh"
)
var (
Default = Build
binary = "spruce"
defaultInstallPrefix = "/usr/local"
)
// Test run the go tests.
// To enable verbose mode set SPRUCE_TEST_VERBOSE=1.
// To enable coverage mode set SPRUCE_TEST_COVER=1.
func Test() error {
goTest := sh.RunCmd("go", "test")
args := []string{"./..."}
if os.Getenv("SPRUCE_TEST_VERBOSE") == "1" {
args = append(args, "-v")
}
if os.Getenv("SPRUCE_TEST_COVER") == "1" {
args = append(args, "-cover")
}
return goTest(args...)
}
// Lint runs golangci-lint against the code.
func Lint() error {
return sh.RunV("golangci-lint", "run", "--color", "always")
}
// Build builds the binary.
func Build() error {
flags := ldflags()
return sh.Run("go", "build", "-ldflags="+flags, "-a", "-o", binary, "./cmd/spruce")
}
// Install installs the binary to the execution path.
func Install() error {
mg.Deps(Build)
installPrefix := os.Getenv("SPRUCE_INSTALL_PREFIX")
if installPrefix == "" {
installPrefix = defaultInstallPrefix
}
dest := filepath.Join(installPrefix, "bin", binary)
if err := sh.Copy(dest, binary); err != nil {
return fmt.Errorf("unable to install %s; %w", binary, err)
}
return nil
}
// Documentation generates AsciiDoc documentation.
func Documentation() error {
documentation, err := sh.Output("go", "run", "./cmd/spruce-docgen")
if err != nil {
return fmt.Errorf("unable to produce documentation; %w", err)
}
filename := "./docs/schema.asciidoc"
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("unable to create %s; %w", filename, err)
}
defer file.Close()
fmt.Fprint(file, documentation)
return nil
}
// Clean cleans the workspace
func Clean() error {
files := []string{binary, "cv.pdf", "cv.json"}
for i := range files {
if err := sh.Rm(files[i]); err != nil {
return fmt.Errorf("unable to remove %s; %w", binary, err)
}
}
if err := sh.Run("go", "clean", "./..."); err != nil {
return fmt.Errorf("unable to run 'go clean'; %w", err)
}
return nil
}
// ldflags returns the build flags.
func ldflags() string {
ldflagsfmt := "-s -w -X main.binaryVersion=%s -X main.gitCommit=%s -X main.goVersion=%s -X main.buildTime=%s"
buildTime := time.Now().UTC().Format(time.RFC3339)
return fmt.Sprintf(ldflagsfmt, version(), gitCommit(), runtime.Version(), buildTime)
}
// version returns the latest git tag using git describe.
func version() string {
version, err := sh.Output("git", "describe", "--tags")
if err != nil {
version = "N/A"
}
return version
}
// gitCommit returns the current git commit
func gitCommit() string {
commit, err := sh.Output("git", "rev-parse", "--short", "HEAD")
if err != nil {
commit = "N/A"
}
return commit
}

13
magefiles/main.go Normal file
View file

@ -0,0 +1,13 @@
//go:build ignore
package main
import (
"os"
"github.com/magefile/mage/mage"
)
func main() {
os.Exit(mage.Main())
}

159
main.go
View file

@ -1,159 +0,0 @@
package main
import (
"embed"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"text/template"
"time"
"codeflow.dananglin.me.uk/apollo/spruce/internal/cv"
tf "codeflow.dananglin.me.uk/apollo/spruce/internal/templateFuncs"
)
//go:embed templates/tex/*
var templates embed.FS
var version string
func main() {
var (
input string
output string
employmentHistory int
printVersion bool
)
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.BoolVar(&printVersion, "version", false, "print the application version and exit.")
flag.Parse()
if printVersion {
Version()
os.Exit(0)
}
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 {
return fmt.Errorf("unable to create a temporary directory; %w", err)
}
defer func() {
err := os.RemoveAll(tempDir)
if err != nil {
log.Printf("WARN: An error occurred when removing the temporary directory; %v", err)
}
}()
texFile, err := tex(input, tempDir, historyLimit)
if err != nil {
return fmt.Errorf("unable to create the tex file; %w", err)
}
if err := pdf(tempDir, texFile, output); err != nil {
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, historyLimit time.Time) (string, error) {
c, err := cv.NewCV(input)
if err != nil {
return "", fmt.Errorf("unable to create a new CV value from %s; %w", input, err)
}
output := filepath.Join(tempDir, "cv.tex")
file, err := os.Create(output)
if err != nil {
return "", fmt.Errorf("unable to create output file %s; %w", output, err)
}
defer file.Close()
fmap := template.FuncMap{
"notLastElement": tf.NotLastElement,
"join": tf.JoinSentences,
"durationToString": tf.FormatDuration,
"withinTimePeriod": tf.WithinTimePeriod(historyLimit),
}
t := template.Must(template.New("cv.tmpl.tex").
Funcs(fmap).
Delims("<<", ">>").
ParseFS(templates, "templates/tex/*.tmpl.tex"),
)
if err = t.Execute(file, c); err != nil {
return "", fmt.Errorf("unable to execute the CV template. %w", err)
}
log.Printf("INFO: Tex file %s was successfully created.", output)
return output, nil
}
// pdf generates the CV document as a PDF file from the tex file.
func pdf(tempDir, texFile, output string) error {
pathArg := "--path=" + tempDir
command := exec.Command("mtxrun", pathArg, "--script", "context", texFile)
command.Stderr = os.Stderr
command.Stdout = os.Stdout
if err := command.Run(); err != nil {
return err
}
if output == "" {
output = "./cv.pdf"
}
if err := copyfile(filepath.Join(tempDir, "cv.pdf"), output); err != nil {
return err
}
return nil
}
func copyfile(input, output string) error {
inputFile, err := os.Open(input)
if err != nil {
return fmt.Errorf("unable to open %s; %w", input, err)
}
defer inputFile.Close()
outputFile, err := os.Create(output)
if err != nil {
return fmt.Errorf("unable to create %s; %w", output, err)
}
defer outputFile.Close()
_, err = io.Copy(outputFile, inputFile)
if err != nil {
return fmt.Errorf("unable to copy %s to %s; %w", input, output, err)
}
return nil
}
func Version() {
fmt.Printf("Spruce version %s\n", version)
}

216
schema/cv.schema.json Normal file
View file

@ -0,0 +1,216 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://codeflow.dananglin.me.uk/apollo/spruce/raw/branch/main/schema/cv.schema.json",
"title": "CV",
"description": "A short written summary of your skills, achievements and experiences in relation to your role.",
"type": "object",
"properties": {
"firstName": {
"description": "Your first name(s).",
"type": "string"
},
"lastName": {
"description": "Your last name.",
"type": "string"
},
"jobTitle": {
"description": "Your current job title.",
"type": "string"
},
"contact": {
"additionalProperties": {
"type": "string"
},
"description": "Your contact information. You can use any key/value pairs here.",
"type": "object"
},
"links": {
"additionalProperties": {
"type": "string"
},
"description": "URLs to your online presence such as GitHub or your website. You can use any key/value pairs here.",
"type": "object"
},
"summary": {
"description": "A list of sentences summarising your skills, experiences and what you'd like to achieve in the near future.",
"items": { "type": "string" },
"type": "array"
},
"skills": {
"description": "A categorised list of your skills.",
"items": { "$ref": "#/$defs/skills" },
"type": "array"
},
"employment": {
"description": "A list of your employment history.",
"items": { "$ref": "#/$defs/employment" },
"type": "array"
},
"education": {
"description": "A list of your education experiences.",
"items": { "$ref": "#/$defs/education" },
"type": "array"
},
"interests": {
"description": "A list of sentences summarising your interests and hobbies.",
"items": { "type": "string" },
"type": "array"
}
},
"required": [
"firstName",
"lastName",
"jobTitle",
"summary",
"skills",
"employment",
"education",
"interests"
],
"additionalProperties": false,
"$defs": {
"skills": {
"properties": {
"category": {
"description": "The skills category.",
"type": "string"
},
"values": {
"items": {
"type": "string"
},
"description": "The skills listed in this category.",
"type": "array"
}
},
"required": [
"category",
"values"
],
"additionalProperties": false,
"type": "object"
},
"employment": {
"properties": {
"company": {
"description": "The company where your work experience took place.",
"type": "string"
},
"duration": {
"description": "The duration of the experience.",
"$ref": "#/$defs/duration"
},
"jobTitle": {
"description": "The job title of your experience.",
"type": "string"
},
"location": {
"description": "The location where the experience was based.",
"type": "string"
},
"locationType": {
"description": "The location type of your work experience (e.g. Remote, Hybrid, On-site).",
"type": "string"
},
"details": {
"description": "Further details of the experience (e.g. achievements, daily responsibilities, etc).",
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"company",
"duration",
"jobTitle",
"location"
],
"additionalProperties": false,
"type": "object"
},
"education": {
"properties": {
"qualification": {
"description": "The qualifications gained from this educational experience.",
"type": "string"
},
"school": {
"description": "The school or university where you have studied.",
"type": "string"
},
"location": {
"description": "The location where the experience was based.",
"type": "string"
},
"duration": {
"description": "The duration of the experience.",
"$ref": "#/$defs/duration"
},
"details": {
"description": "Further details of the experience.",
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"qualification",
"school",
"location",
"duration"
],
"additionalProperties": false,
"type": "object"
},
"duration": {
"properties": {
"start": {
"description": "The start date of the experience.",
"$ref": "#/$defs/date"
},
"end": {
"description": "The end date of the experience.",
"$ref": "#/$defs/date"
},
"present": {
"description": "Specifies whether you are currently employed or studying at the specified company or educational institute.",
"type": "boolean"
}
},
"additionalProperties": false,
"required": [ "start" ],
"type": "object"
},
"date": {
"properties": {
"year": {
"description": "The year (e.g. 2023).",
"type": "integer"
},
"month": {
"description": "The numerical value of the month (e.g. 5 for May).",
"minimum": 1,
"maximum": 12,
"type": "integer"
},
"day": {
"description": "The day of the month.",
"minimum": 1,
"maximum": 31,
"type": "integer"
}
},
"additionalProperties": false,
"required": [
"year",
"month",
"day"
],
"type": "object"
}
}
}