Compare commits

..

1 commit

Author SHA1 Message Date
2716861372
build the app with dagger 2023-03-09 23:18:54 +00:00
36 changed files with 514 additions and 1712 deletions

5
.dockerignore Normal file
View file

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

View file

@ -1,37 +0,0 @@
---
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,5 +43,4 @@ tags
[._]*.un~
cv.pdf
cv.json
/spruce
spruce

View file

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

21
Makefile Normal file
View file

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

View file

@ -1,210 +0,0 @@
= 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.

46
README.md Normal file
View file

@ -0,0 +1,46 @@
# 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.

View file

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

View file

@ -1,123 +0,0 @@
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())
}
}

55
dagger/dagger.go Normal file
View file

@ -0,0 +1,55 @@
//go:build dagger
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"dagger.io/dagger"
)
func main() {
if err := build(context.Background()); err != nil {
log.Printf("ERROR: %v", err)
os.Exit(1)
}
}
func build(ctx context.Context) error {
log.Println("Building with Dagger.")
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stdout))
if err != nil {
return fmt.Errorf("unable to create the dagger client; %w", err)
}
defer client.Close()
rootDir, err := filepath.Abs(".")
if err != nil {
return err
}
image := "golang:1.20"
containerWorkspace := "/workspace"
builder := client.Container().
From(image).
WithMountedDirectory(containerWorkspace, client.Host().Directory(rootDir)).
WithWorkdir(containerWorkspace).
WithEnvVariable("CGO_ENABLED", "0").
WithEnvVariable("GOOS", "linux").
WithEnvVariable("GOARCH", "amd64").
WithExec([]string{"make", "clean", "spruce"})
_, err = builder.Stdout(ctx)
if err != nil {
return fmt.Errorf("unable to build the binary; %w", err)
}
return nil
}

View file

@ -1,32 +1,22 @@
# syntax=docker/dockerfile:1
FROM golang:1.21-alpine AS builder
FROM golang:1.19-buster AS spruce-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 mage build
RUN go build -a -v -o /workspace/spruce
FROM alpine:3.18
FROM alpine:3.17
COPY --from=builder /workspace/spruce /usr/local/bin
COPY --from=spruce-builder /workspace/spruce /usr/local/bin
# Install dependencies
RUN apk upgrade --no-cache \
&& apk add --no-cache \
&& apk add \
font-carlito \
aspell \
curl \
@ -43,5 +33,3 @@ 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,175 +0,0 @@
= 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,8 +41,7 @@
"employment": [
{
"company": "Company C",
"location": "Manchester",
"locationType": "Remote",
"location": "Manchester (Remote)",
"jobTitle": "Software Engineer (Cloud Platform)",
"duration": {
"start": {
@ -60,8 +59,7 @@
},
{
"company": "Company B",
"location": "London",
"locationType": "Hybrid",
"location": "London (Hybrid)",
"jobTitle": "Software Engineer (Backend)",
"duration": {
"start": {
@ -83,8 +81,7 @@
},
{
"company": "Company A",
"location": "London",
"locationType": "On-site",
"location": "London (Onsite)",
"jobTitle": "Software Engineer (Backend)",
"duration": {
"start": {
@ -106,8 +103,7 @@
},
{
"company": "Engineering Department, Example University",
"location": "London",
"locationType": "On-site",
"location": "London (Onsite)",
"jobTitle": "Research and Development Assistant",
"duration": {
"start": {

13
go.mod
View file

@ -1,5 +1,14 @@
module codeflow.dananglin.me.uk/apollo/spruce
go 1.21
go 1.20
require github.com/magefile/mage v1.15.0
require dagger.io/dagger v0.5.0
require (
github.com/Khan/genqlient v0.5.0 // indirect
github.com/adrg/xdg v0.4.0 // indirect
github.com/iancoleman/strcase v0.2.0 // indirect
github.com/vektah/gqlparser/v2 v2.5.1 // indirect
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
)

114
go.sum
View file

@ -1,2 +1,112 @@
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
dagger.io/dagger v0.5.0 h1:7hQnA/pFMpEkuaU2ScNWoZznTa6DbIqHnwBJwL5bBQY=
dagger.io/dagger v0.5.0/go.mod h1:1nbGnLdIfoBV2ahbQjheI//SNGz+b5q1jqf0A+pJ+Oc=
github.com/99designs/gqlgen v0.17.2/go.mod h1:K5fzLKwtph+FFgh9j7nFbRUdBKvTcGnsta51fsMTn3o=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Khan/genqlient v0.5.0 h1:TMZJ+tl/BpbmGyIBiXzKzUftDhw4ZWxQZ+1ydn0gyII=
github.com/Khan/genqlient v0.5.0/go.mod h1:EpIvDVXYm01GP6AXzjA7dKriPTH6GmtpmvTAwUUqIX8=
github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls=
github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E=
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
github.com/agnivade/levenshtein v1.1.0/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
github.com/alexflint/go-arg v1.4.2/go.mod h1:9iRbDxne7LcR/GSvEr7ma++GLpdIU1zrghf2y2768kM=
github.com/alexflint/go-scalar v1.0.0/go.mod h1:GpHzbCOZXEKMEcygYQ5n/aa4Aq84zbxjy3MxYW0gjYw=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/bradleyjkemp/cupaloy/v2 v2.6.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/iancoleman/strcase v0.2.0 h1:05I4QRnGpI0m37iZQRuskXh+w77mr6Z41lwQzuHLwW0=
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/kevinmbeaulieu/eq-go v1.0.0/go.mod h1:G3S8ajA56gKBZm4UB9AOyoOS37JO3roToPzKNM8dtdM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc=
github.com/matryer/moq v0.2.3/go.mod h1:9RtPYjTnH1bSBIkpvtHkFN7nbWAnO7oRpdJkEIn6UtE=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mitchellh/mapstructure v1.2.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/vektah/gqlparser/v2 v2.4.0/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0=
github.com/vektah/gqlparser/v2 v2.4.5/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0=
github.com/vektah/gqlparser/v2 v2.5.1 h1:ZGu+bquAY23jsxDRcYpWjttRZrUz07LbiY77gUOHcr4=
github.com/vektah/gqlparser/v2 v2.5.1/go.mod h1:mPgqFBu/woKTVYWyNk8cO3kh4S/f4aRFZrvOnp3hmCs=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200815165600-90abf76919f3/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View file

@ -1,31 +0,0 @@
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())
}
}

View file

@ -1,142 +0,0 @@
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()),
},
},
},
}
}

View file

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

View file

@ -1,56 +0,0 @@
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,52 +1,25 @@
package cv
/*
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"`
}
import (
"os"
"fmt"
"encoding/json"
)
type Date struct {
Day int64 `json:"day"`
Month int64 `json:"month"`
Year int64 `json:"year"`
}
// 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)
}
type Duration struct {
End *Date `json:"end,omitempty"`
Present bool `json:"present,omitempty"`
Start Date `json:"start"`
}
decoder := json.NewDecoder(file)
type Education struct {
Details []string `json:"details,omitempty"`
Duration Duration `json:"duration"`
Location string `json:"location"`
Qualification string `json:"qualification"`
School string `json:"school"`
}
var c CV
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"`
}
if err = decoder.Decode(&c); err != nil {
return CV{}, fmt.Errorf("unable to decode JSON data; %w", err)
}
type Skills struct {
Category string `json:"category"`
Values []string `json:"values"`
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

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

View file

@ -1,3 +0,0 @@
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."

View file

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

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

View file

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

View file

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

View file

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

View file

@ -1,11 +0,0 @@
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,132 +0,0 @@
//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
}

View file

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

159
main.go Normal file
View file

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

View file

@ -1,216 +0,0 @@
{
"$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"
}
}
}

View file

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